fix(cli): close OpenTUI submit-path gaps, restore its E2E leg - #10831
Conversation
…d mid-turn gate Three gaps left the OpenTUI interactive E2E leg red while the ink leg stayed green on the same run: - a composer submission never carried its raw text, so UserPromptSubmit hooks saw no submitted_prompt (and the external-context auto-recall keyed off it never fired), and `@path` mentions reached the model unexpanded; - the approval-mode status text was computed but never rendered, so a YOLO session showed nothing to wait on at startup; - a slash command submitted mid-turn was dispatched at once instead of waiting for idle, so `/compress` raced the still-open stream and silently did nothing. The mid-turn gate is the ink rule: only a command that opted into canRunDuringStreaming runs while a turn responds, everything else the dispatcher would take is held and drained on the idle transition.
|
Thanks for the PR! Template looks good ✓ Problem: observed, not theoretical. The OpenTUI interactive E2E leg went red on Direction: aligned. This is repair work on the active ink → OpenTUI migration (#8662, open, Size: no core paths touched — all nine files live in Approach: scope feels right — three fixes, each mapped to an observed gap, and each reuses an existing ink primitive ( Risk: no high-risk path matches; no elevated risk signals. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到的问题,不是理论性的。OpenTUI 交互式 E2E 在 方向:对齐。这是 ink → OpenTUI 迁移(#8662,开放中, 规模:未触及核心路径——九个文件全部位于 方案:范围合理——三处修复各自对应一个已观测到的缺口,且都复用了现有的 ink 原语( 风险:未命中高风险路径;无升级风险信号。 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewThe approach matches what I'd have proposed independently: thread the raw composer text through the existing send-options seam (rather than reconstructing provenance downstream), render the status string that was already computed, and gate mid-turn slash submissions on the existing per-command opt-in flag. Good reuse throughout — ink's Details I verified against the base code:
No correctness blockers found. Three non-blocking notes:
The mid-turn gate is the one genuinely new flow, for orientation: sequenceDiagram
participant P1 as User
participant P2 as OpenTuiApp onSubmit
participant P3 as Slash gateway
participant P4 as Deferred queue
P1->>P2: submit text while a turn streams
P2->>P3: mustDeferDuringStreaming (awaits ready)
P3-->>P2: true when the command has no streaming opt-in
P2->>P4: hold the command, notify queued
Note over P2,P4: streaming flips false at turn end
P2->>P3: dispatch held commands in submission order
P3-->>P2: outcome per command
alt a submit_prompt outcome starts a new turn
P2->>P4: remaining commands wait for the next idle
end
Test evidence (this PR's own CI — unattended run, no local execution)One important wrinkle: #10829 merged today and removed the OpenTUI interactive leg from CI entirely, so the four oracle tests for this PR ( What the PR's CI does cover, as of this fetch (the finalize job updates the table when CI settles): Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 The unit suite and the no-AK integration leg are still running. The two checks most relevant to a parity PR — TUI parity snapshots (ink vs opentui) and the OpenTUI no-flicker gate — are already green on this commit. Sandboxed verification would settle the rest: Not verified: the four interactive failures themselves (no CI leg runs them; on the CI path we do not execute PR code). The description's "Evidence (Before & After)" output is the author's claim from local runs, not independently re-run here. 中文说明代码审查实现思路与我独立的方案一致:把用户键入的原文经由既有的 send-options 接缝透传(而不是在下游重建来源信息)、渲染本来就计算好的状态文本、并用逐命令既有的选择加入标志来约束流中的斜杠命令。复用做得好—— 对照基线代码核实过的细节:
未发现正确性阻塞项。三条非阻塞备注:
流中门禁是唯一真正新增的流程,示意图供导航:用户流中提交 → 门禁判定需延后 → 命令入队并提示 → 回合结束(streaming 变 false)后按提交顺序逐个派发;若某个结果是 submit_prompt 并开启新一轮,其余命令等待下一次空闲。 测试证据(本 PR 自身 CI——无人值守运行,不在本地执行)一个重要变化:#10829 今天已合入,OpenTUI 交互腿已从 CI 完全移除,因此本 PR 的四个锚定用例(OpenTUI 渲染器下的 本 PR CI 实际覆盖的部分,截至本次抓取(finalize 任务会在 CI 落定后就地更新表格,见英文部分的机器可读区域)。单元测试与 no-AK 集成腿仍在运行。对 parity PR 最相关的两项检查——**TUI parity snapshots(ink vs opentui)**与 OpenTUI no-flicker gate——在该提交上已经转绿。 沙箱验证可以补上剩余部分: 未验证:四个交互失败用例本身(没有 CI 腿运行它们;CI 路径上不执行 PR 代码)。描述中"证据(改动前后)"一节是作者本地运行的声明,此处未独立复跑。 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — clean, minimal parity fix with good reuse and solid unit coverage; only non-blocking nits, but the behavioral oracle has no CI leg to run it. Stepping back: the motivation is solid — these are real gaps that made a main-branch E2E leg fail deterministically, not speculative hardening, and the diff is exactly the minimal set: thread provenance, render the already-computed status, gate mid-turn commands like ink does. My independent proposal would have landed on the same three fixes using the same ink primitives, so there's no simpler path being missed. The code reads like it will be easy to maintain in six months — the comments say why (ink parity) at every non-obvious point, and the deferred-queue logic handles its edge cases (double-dispatch, submit_prompt re-queueing) correctly. Reservations, all non-blocking: the bad- Approval is deferred until CI lands green on this commit; the finalize job posts the commit-pinned approval if everything settles green, and withholds it if anything lands red or the head moves. 中文说明置信度:4/5 —— 干净、最小化的 parity 修复,复用得当、单测覆盖扎实;只有非阻塞的小问题,但行为锚定用例没有 CI 腿可以运行它。 退一步看:动机扎实——这些是让 main 分支 E2E 腿确定性失败的真实缺口,不是理论性加固;diff 也恰好是最小集合:透传来源字段、渲染已算好的状态文本、像 ink 一样约束流中命令。我的独立方案会落在同样的三处修复、复用同样的 ink 原语,没有更简的路径被遗漏。代码六个月后维护起来也不会令人头疼——每个非显而易见处都有说明"为什么"(与 ink 对齐)的注释,延后队列逻辑的边界情况(重复派发、submit_prompt 重新入队)处理正确。 保留意见,均为非阻塞:审查中提到的坏 批准将推迟到该提交的 CI 全部转绿后执行:CI 全部转绿时由 finalize 任务提交锚定该提交的批准;若有检查变红或提交移动,则不予批准并说明。 — Qwen Code · qwen3.8-max Reviewed at |
#10829 removed the `e2e-interactive-opentui` job to keep main green, and registered its removal as gated on the parity gaps the leg surfaced (#8662 U-17…U-20). The commits above this one close them: the approval-mode indicator is drawn, `submitted_prompt` rides the first send, `@file` goes through query preparation, and a slash command submitted mid-turn waits for idle instead of racing the open stream. Restoring the job verbatim rather than rewriting it: nothing about its runtime contract was wrong, and the pinned Bun version plus the strict renderer pin are what make a silent ink fallback fail the leg. The four failures traced to four gaps, not the three #10829 recorded — the context-compression case needed its own mid-turn gate. The context-compress file additionally carries a fifth case (`/compress` with trailing instructions) that was red on the first two `main` runs and passed the third: it submits mid-turn too, so it is the same mechanism, and the leg that runs the whole directory is what judges it. The design doc named the leg as removed in two places; both now describe a leg that reported the gaps, left CI, and came back with them closed.
E2E test report — OpenTUI interactive legWhat ran, and whereThe leg this PR restores has no
ResultThe five cases that had been red on
The baseline for comparison is the job's own last Locally, on the merged treeSame five cases in one vitest process (macOS, Bun, OpenTUI, live model for the compression cases) — run to get per-case timings and to catch anything Linux-only ordering hides: The terminal capture shows the new gate firing mid-turn — Supporting: unit tests for the touched area 6 files / 147 tests green on the merged tree; What this evidence does not cover
|
…chrome A submission that arrives while a turn responds becomes steering, which carries text only, so expanding its @ mention read files the hop would drop. A declined expansion also stayed silent: the composer cleared and nothing reached the model. Report the failed-read reason instead, and pin the approval-mode indicator plus all three submit-path rules in units.
Review notes — all three addressed on
|
|
@qwen-code /tmux Real-session pass over the changed submit path — branch
|
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 37 passed · 0 failed · 37 total Flakiness gate: 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:37 通过 · 0 失败 · 37 总计 抖动门: Verification reportPR 10831 Deep Verification — fix(cli): close OpenTUI submit-path gaps, restore its E2E legVerdict: 中文摘要
Central claim and A/BCentral claim: a slash command submitted while a model turn is still streaming is held back and only runs once the turn ends (ink parity); opted-in commands are unaffected. Secondary: (a) The control is a second bundle built in a
All four base failures carry the exact intended messages (e.g. One environment accommodation, A/A-proven rather than assumed: this container exhibits a pre-existing opentui startup race (Finding 1) that fails any first submission landing ~1 s after readiness, on both arms identically. The leg cells above therefore settle 8 s after readiness before typing; the same 8 s settle was applied to both arms, and the unsettled A/A probes ( CorrectionsNone — first verification round; no earlier bot claims to correct. (One record note: the snapshot Findings (non-blocking; none introduced by this PR)1. Pre-existing opentui startup race: first submit inside the init window →
|
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Reviewed at head 8bc4fcab.
- The four parity gaps are closed with correct ink semantics, each verified in code: the mid-turn gate defers only dispatcher-taken slash commands (btw and
/-path passthrough keep their submit-immediately behavior), the deferred queue drains in submission order on the streaming→idle transition without re-deferring;submitted_promptprovenance rides the first UserQuery only, with continuations omitting it by construction;@-expansion runs off-stream only (steering stays text-only per its contract), and its decline path — previously a silent vanishing submission — now surfaces the failed-read notice; the approval-mode status text is actually rendered and localized. - The restored E2E leg is byte-for-byte the one removed in #10829 (pinned Bun, strict renderer mode), which is exactly what that PR's un-gating condition called for, and the design doc's gap ledger is updated in the same commit — including the honest correction that the fifth failing case shared the mid-turn mechanism rather than being a sixth gap.
- ~226 lines of new tests across the five changed modules pin the queue ordering, expansion skip mid-turn, provenance plumbing, and indicator rendering. CI has no failures on this head (lanes still running after the push); no prior reviews or open threads. Per the channel convention the call is on the review itself.
chiga0
left a comment
There was a problem hiding this comment.
Review — fix(cli): close OpenTUI submit-path gaps, restore its E2E leg
No blocking findings. Approval withheld: reviewer is the PR author.
Scope
Reviewed: all 12 changed files — 7 TypeScript source + test files in packages/cli/src/ui/opentui/, e2e.yml, and the design doc.
Not covered: integration-test execution (requires live API keys + bun runtime); approval-mode indicator visual rendering.
Triage: Standard
Touches runtime UI behaviour (submit-path dispatch, streaming gate, provenance threading) with test coverage, but no persisted format or wire protocol. Reversible.
Checked (all clean)
| Item | Result |
|---|---|
mustDeferDuringStreaming semantic inversion |
Correctly inverts canRunDuringStreaming: btw/path pass-through → false (don't defer); unknown commands → true (defer, safe default); known commands → derived from canRunDuringStreaming. All 6 cases covered by the revised test. ✅ |
takesAsSlashCommand refactor |
Private helper is logically equivalent to the old two-condition guard in handle() — same startsWith checks, same path-separator bail-out. ✅ |
slash-gateway made async |
mustDeferDuringStreaming now awaits this.ready before answering, so mid-turn gate decisions wait for the command registry to load instead of defaulting to false (let it run) on an unpopulated registry. Previous sync form was a latent false-green. ✅ |
| Deferred command queue | Queue cleared synchronously before the async IIFE; concurrent effect fires cannot double-process the same commands. submit_prompt mid-queue correctly re-enqueues the tail and returns, waiting for the next idle edge. ✅ |
submittedPrompt threading |
Flows from app-shell → OpenTuiSubmitOptions → LivePromptOptions → sendOptions on first UserQuery only; tool-result continuations omit it by construction. Test asserts both first-call and second-call shapes. ✅ |
sendOptions always explicit on first send |
Previously undefined when no modelOverride — now always { type: UserQuery }. Core-side this was a no-op (treated as a user query anyway), but making it explicit unblocks provenance attachment. ✅ |
@-expansion mid-turn skip |
isAtCommand expansion only runs when !streaming; mid-turn submission forwards raw text as steering (expansion would be read and dropped anyway). ✅ |
| Approval-mode indicator rendering | chrome.statusText was computed but never rendered before. Now rendered in the JSX. t() wrapper added so tests assert through the same translation call the component makes, preventing locale-flip silent breaks. ✅ |
| CI leg restored byte-for-byte | e2e-interactive-opentui job command is identical to the one removed in #10829. Comment reverted to match. ✅ |
.size-baseline not required |
Restored e2e.yml is ~24,400 bytes (baseline 21,437 + allowance 4,096 = 25,533 ceiling — within bounds). ✅ |
Cross-check
qwen-code-dev-bot approved at 8bc4fcab: confirmed same four gap-closures (mid-turn gate, queue drain, submitted_prompt provenance, @-expansion off-stream only). Consistent with my findings. No missed items.
Reviewed with AI assistance.
yiliang114
left a comment
There was a problem hiding this comment.
Reviewed the full diff at 8bc4fca and ran the touched suites locally.
What I checked
submittedPromptprovenance: rides the firstUserQuerysend only;ToolResultcontinuations omit it by construction — matches core's rule that only UserQuery honourssubmitted_prompt.@-mention parity: idle submissions expand viahandleAtCommandwith the raw composer text kept as provenance; mid-turn submissions forward as plain text (steering drops expansion); a declined expansion surfaces the read failure instead of silently sending.- Approval-mode indicator is now actually rendered (with
t()for i18n), asserted for YOLO/AUTO_EDIT/AUTO and absent for PLAN/DEFAULT. - Mid-turn slash gate:
mustDeferDuringStreamingcorrectly exempts non-command input (/-paths, btw side-questions), held commands replay in order on the idle edge, and asubmit_promptoutcome re-queues the rest rather than racing the new stream. - Restored e2e leg: pinned bun 1.3.14, fork-PR skip, 60min timeout, strict renderer pin rationale documented.
Local verification at head
commands-dispatch+input-prompt+live-session+opentui-app-shell→ 143/143 passed- TUI parity snapshots and OpenTUI no-flicker gate already green in CI.
LGTM, approving.
qqqys
left a comment
There was a problem hiding this comment.
Critical: /quit (and /exit) typed mid-turn is now queued instead of exiting
Where — packages/cli/src/ui/opentui/opentui-app-shell.tsx:374-381 (the new mid-turn gate at the top of onSubmit), with the answer coming from mustDeferDuringStreaming in packages/cli/src/ui/opentui/commands-dispatch.ts.
Trigger — streaming === true and the user submits /quit or /exit.
quitCommand carries no canRunDuringStreaming opt-in (packages/cli/src/ui/commands/quitCommand.ts; the opt-ins today are about/bug/docs/editor/help/hooks/lsp/settings/tasks), so the new gate defers it exactly like /compress.
Verified at head 8bc4fcabc2
Real OpenTuiSlashDispatcher against the real built-in commands, no stubs:
mustDeferDuringStreaming >>> {"/quit":true,"/exit":true," /quit ":true,"/help":false,"/compress":true,"/clear":true,":q":false,"plain prompt":false}
quitCommand.canRunDuringStreaming >>> undefined | helpCommand >>> true
Shell half of the same production path (streaming: true, dispatcher answering what it really answers for /quit):
mid-turn /quit >>> onQuit calls: 0 | dispatched texts: []
after idle >>> onQuit calls: 1 | dispatched texts: ["/quit"]
with the notice Queued /quit — it will run when the current response ends. rendered in place of an exit.
Isolating the cause: removing only the new gate hunk (file restored byte-identical, sha256 d8d90daf85b8…) flips the mid-turn result to onQuit calls: 1 | dispatched texts: ["/quit"]. So the hunk is the sole cause and this is a regression against the PR's base, not pre-existing behavior — before it, /quit reached onQuit → exitSession(0) immediately regardless of streaming.
152 tests across the six touched opentui suites are green at head (including the ones added in 8bc4fcabc2); nothing pins quit either way.
Impact
The exit command does not exit. The turn keeps running — and keeps spending tokens — after the user asked to leave, and the CLI exits only when it ends; if the turn never completes, the queued /quit never runs at all.
The keyboard cascade is not an equivalent escape hatch: while streamingRef.current is true, Ctrl+C/Ctrl+D call interrupt() and return (packages/cli/src/ui/opentui/start-opentui-ui.tsx:192-210), so reaching the two-press exit window still requires the turn to stop first — and that path leaves via EXIT_CODE_INTERRUPT instead of the graceful exitSession(0).
Why this is not ink parity
ink places an explicit bypass before its queue — packages/cli/src/ui/AppContainer.tsx:2881-2889:
// Quit must bypass reminders and the message queue so it can stop an
// active stream without consuming one-shot session state.
if (['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(userPromptText.trim())) {
void handleSlashCommand('/quit');
return;
}
The PR's Risk & Scope says the mid-turn wait "is ink's behaviour". That holds for /compress and the rest, but ink deliberately exempts quit for the reason in that comment. (:q escapes the gate here only incidentally — takesAsSlashCommand rejects it, and the dispatcher hands non-slash text back to the model.)
Fix direction
Exempt quit in onSubmit ahead of the gate at line 374, mirroring ink's list. Setting canRunDuringStreaming: true on quitCommand would also work mechanically, but that flag is shared with ink's own mid-stream fast path, so the local guard is the smaller and safer change.
Everything else I checked in this PR held up, for the record: the restored leg cannot pass as a false green (resolveE2eCliCommand hard-fails without bun and e2eRendererEnv pins QWEN_TUI_RENDERER_STRICT=1, integration-tests/renderer-matrix.ts:46-66); the three new t() keys exist in the locale maps; the pinned Bun 1.3.14 matches DEFAULT_BUN_VERSION (scripts/build-standalone-release.js:64); QWEN_SKIP_PREPARE is the pattern the other four e2e legs already use; and the new job id is unique with no dangling references. The three fixes in 8bc4fcabc2 (mid-turn expansion skipped, declined expansion reported, indicator pinned) also look right.
chiga0
left a comment
There was a problem hiding this comment.
Round 2 — correction of my round-1 verdict. My earlier review (5091324625) concluded "no blocking findings" at this same head/base. That was wrong: the cross-check against @qqqys's changes-requested review surfaced a blocker I had missed, and I have now verified it myself at head 8bc4fcab. Everything else in round 1 stands; the verdict does not.
R2-1 — Blocker (confirmed, at head 8bc4fcab): /quit and /exit typed mid-turn are queued until idle instead of exiting. Confirming qqqys's finding with my own verification of every link:
quitCommand.tshas zero occurrences ofcanRunDuringStreaming, so the new gate inopentui-app-shell.tsx:374defers/quit//exitexactly like/compress(mustDeferDuringStreamingincommands-dispatch.tsreturns true for both).- This diverges from ink by design, not by accident:
AppContainer.tsx:2881-2890places an explicit bypass before its queue for['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!']— "Quit must bypass reminders and the message queue so it can stop an active stream without consuming one-shot session state." The opentui gate has no such exemption. (My round-1 ink comparison started reading atAppContainer.tsx:2925and missed the bypass immediately above — that is the miss.) - No equivalent escape hatch while streaming:
start-opentui-ui.tsx:204-207— Ctrl+C/Ctrl+D callinterrupt()and return whilestreamingRef.current; the two-press exit window only arms once idle, and leaves viaEXIT_CODE_INTERRUPTrather than the gracefulexitSession(0). - Regression against this PR's own base: before the gate existed, opentui dispatched
/quitimmediately regardless ofstreaming. - Impact: after the user asks to leave, the turn keeps running (and spending tokens) until it ends; a turn that never completes means the queued
/quitnever runs.
Fix direction agreed with qqqys: exempt quit in onSubmit ahead of the gate, mirroring ink's list; setting canRunDuringStreaming: true on quitCommand would work mechanically but that flag is shared with ink's own mid-stream fast path, so the local guard is the smaller change.
Round-1 findings status: none were filed; the round-1 scope ledger and verification remain valid —
Checked (unchanged from round 1): the workflow restore is byte-for-byte the job #10829 removed (66/66 lines identical by mechanical comparison) · core honours submittedPrompt only on a non-empty UserQuery (client.ts:2789, 3121-3131, 3156-3174), and live-session.ts rides it on the first send only · the one handleAtCommand decline path always appends a string resultDisplay last (atCommandProcessor.ts:938-962) · YAML parses, workflow suites 226/226, size ratchet exit 0 · CI premise re-verified: run 33638984513's opentui leg is SUCCESS (the run's other failures are unrelated legs), and it executed at 47f594ae — one commit before final head; the post-run delta (mid-turn @-expansion skip + decline notice + their tests) is unit-covered and the leg's four files don't touch those paths, but no leg run exists at the exact head sha.
Ran (linux, head 8bc4fcab): vitest packages/cli src/ui/opentui → 67 files, 1044/1044 passed (after repairing my sandbox's nested-dep resolution: packages/core/node_modules/ajv@8.20.0 and cli wrap-ansi@10.0.0 — the initial broken resolution produced 49 load-failures plus one spurious a11y failure that disappeared once it matched the lock; noted so nobody re-derives a defect from it) · two mutation probes killed (removing the statusText render flips the 3 positive indicator pins; removing the !streaming && guard flips the mid-turn @ pin) · typecheck run here: it reports errors, but none in any file this PR touches — they are this sandbox resolving workspace packages to a co-checked-out clone's stale built types rather than head's sources (first attempt OOM'd; retry's errors all sit in unrelated serve/acp-integration/peerMessaging/bridge files), so it neither confirms nor disputes the PR's "typecheck clean" claim.
Not covered: live-model/Bun execution (needs CI secrets) · Windows (leg is ubuntu-only).
Reviewed with AI assistance.
|
tmux real-user testing: timeout - workflow run The tmux test did not complete before the time limit. This is not a pass/fail result for the affected flow; see the workflow run and artifacts for details. No report.md or tmux-readable-full.log was found in tmux-results, so detailed report sections are omitted. — Qwen Code · tmux real-user testing |
…turn quit The commit above kept mid-turn @ mentions out of expansion on the stated premise that steering drops the expanded payload. That premise was wrong: ink expands steered messages too (resolveSteeredMessages), so the composer-side guard was a parity loss rather than a parity guard. Expansion moves to where a prompt enters the stream, which is where ink does it for a fresh turn (processQuery): the idle submit and the queued text that becomes the next turn are both covered, the transcript keeps what the user typed, and the read cards ink renders through handleAtCommand's addItem now render here as settled tool-start/tool-result/tool-end events. A declined expansion is the failed read, and it reports itself on that card instead of sending the unexpanded text to the model. The follow-on turn built from the mid-turn queue carries the raw text as UserPromptSubmit provenance for the same reason ink does. Text drained as in-flight steering still rides raw. ink expands that hop with a 10s read timeout and a queue restore on cancel; reproducing it half-way under a red CI leg was the worse option, so the divergence is stated in the live-turn module docstring and registered in #8662 instead. Review round 2 also found a blocker in the mid-turn gate this branch added: the quit command carries no canRunDuringStreaming opt-in, so /quit and /exit typed during a response were queued until it ended. ink runs its quit family ahead of the message queue precisely so an exit can stop an active stream, and its quit action cancels the ongoing request before the exit drains. The gate now exempts the resolved quit command, which covers /exit as an altName, and the shell interrupts the turn before handing the exit to the entry so the cleanup drain does not race a stream that is still writing. Pinned in units at both seams and mutation-checked: the gate answers from the real built-in quit command, the shell interrupts before it exits, the stream layer expands a string prompt and reports a failed read, and the queued follow-on turn arrives raw with its provenance. No ink-path file is touched.
7b65258
|
R2-1 fixed at head @qqqys your repro was right and my round-1 "no blocking findings" verdict was wrong. Two changes, at the layers ink puts them:
Verification — real dispatcher and real shell, not a stub asserting a stub:
Coverage boundary, stated plainly: there is no live E2E case for a mid-turn Also in this commit, found by re-reading the same path rather than from the review: One pre-existing gap this brushes against, unchanged here: ink also quits on the bare tokens |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/e2e.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
Test Plan (not a blocker): interactive/context-compress-interactive.test.ts — no such file or directory; interactive/external-context-auto-recall.test.ts — no such file or directory; interactive/protocol-tags-interactive.test.ts — no such file or directory; interactive/submitted-prompt-provenance.test.ts — no such file or directory.
中文说明
未检查(工具限制,非阻断):the executable-script lint — .github/workflows/e2e.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
Test Plan(非阻断):interactive/context-compress-interactive.test.ts — no such file or directory; interactive/external-context-auto-recall.test.ts — no such file or directory; interactive/protocol-tags-interactive.test.ts — no such file or directory; interactive/submitted-prompt-provenance.test.ts — no such file or directory。
— qwen3.8-max via Qwen Code /review (v0.22.3)
|
@qwen-code /takeover |
|
🔀 Base updated: red check(s) [Dependency CVE audit] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Dependency CVE audit] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply 中文说明👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 |
…eline Restoring the OpenTUI interactive e2e job that #10829 removed grows e2e.yml by 4454 bytes, past the 21437+4096 ratchet. The growth is the job block itself, not drift: bump the recorded size to 25891 in the same PR, as the gate instructs.
chiga0
left a comment
There was a problem hiding this comment.
Round 3 — the head moved twice since round 2 (6c2dfdc4 reviewed in full; final head 92af4f55 adds only a merge of main whose sole file is an unrelated vscode-ide-companion/NOTICES.txt, so all verification carries over).
Prior-round blocker — fixed and pinned. My R2-1 / @qqqys's /quit finding is resolved: mustDeferDuringStreaming exempts the resolved quit command (so the /exit altName is covered) ahead of the opt-in check (commands-dispatch.ts:390-398); the quit outcome discards both mid-turn queues, interrupts the stream, then exits (opentui-app-shell.tsx:360-375 → handleQuit → exitSession(0)), mirroring ink's "cancel the ongoing request before the exit drains". Pinned by three tests (commands-dispatch.test.ts:416, opentui-app-shell.test.tsx:544/:743); my mutation probe (removing the exemption line) turns the dispatcher pin red.
Cross-check on the ci-bot's findings (frozen before reading them; verdicts at head):
- Its round-1 series is correctly dispositioned: the nine implemented fixes are real at head (verified in the
c27d7d49c5diff — the tests cite R1-1/R1-5/R1-7/R1-12 by name, and the suite is green below); the R1-11 decline holds — I checked its basis at head: ink'spopNextSubmissionreally is plain-first (useMessageQueue.ts:296), so "one turn late" matches ink rather than diverging from it; R1-13 (prompt-side vision bridge missing under vision-bridge configs) is verified real and deferred to the follow-up queue by agreement — disclosed, not silently dropped. - Its round-2 series was filed after the last code commit and nothing since addresses it (the takeover was released; only merges landed). I verified both Criticals independently at head — they hold:
- R3-1 (their R2-1), Blocker: the drain loop runs on its own
pendingsnapshot and never consults exit state. The composer quit path clears the ref, but an already-running drain keeps its snapshot, and the two exit paths that bypass the shell — Ctrl+C/Ctrl+D two-press (start-opentui-ui.tsx:210) and the render-error bailout (:237) — never clear it at all.exitSessionkeeps the tree mounted while it drains the cleanup chain (exit-lifecycle.ts:46-62), and theisExitInProgress()latch that would guard this sits unused. Held commands (a/compressmodel call, or asubmit_promptstarting a fresh turn) can dispatch into teardown. - R3-2 (their R2-2), Blocker: the end-of-turn replay joins ALL queued mid-turn texts into one turn (
live-turn.tsfinally-block), draining the queue irreversibly before expansion; the one decline of the expander — a failed or aborted@-read — returns without sending, so every queued message is lost, violating the module's own "user input is never silently dropped" invariant. ink pops one submission per settle precisely so an abort risks only that one. - R3-3 (their R2-3), Suggestion: the multimodal gate test passes no options, so the
typeof prompt === 'string'guard is the only expansion defense never exercised — the bot's named mutant (deletingtypeof) survives the test as written; production always carries provenance on this seam, so the mutant would throw in real use.
- R3-1 (their R2-1), Blocker: the drain loop runs on its own
- The bot's round-2 deferred probe (input typed during the drain batch busy-rejected) stays recorded-but-not-requested per its own convergence note.
Verification at head (linux): vitest packages/cli src/ui/opentui → 67 files, 1054/1054 passed · vitest scripts suites (e2e-workflow/e2e-shard-retry/workflow-size) → 227/227 · size ratchet exit 0 · e2e.yml restore still byte-for-byte the job #10829 removed (66/66 lines, re-checked at final head 92af4f55 — it survived three merges of main) · quit-exemption mutation killed. Typecheck caveat unchanged from round 2 (this sandbox resolves workspace types against a co-clone's stale build; nothing in PR-touched files).
Not covered: live-model/Bun leg at this head (no run cited past 47f594ae+33642038701; the leg's four files don't touch the drain/steering paths, but a fresh dispatch would pin it) · Windows N/A.
Verdict: no approval (I am the author, and two confirmed blockers — R3-1/R3-2 — are open on record and verified at head). The quit regression that blocked round 2 is properly fixed; what remains is the drain's exit-blindness and the joined-queue drop, both on the ci-bot's threads, both needing either a fix or an explicit maintainer disposition before merge.
Reviewed with AI assistance.
yiliang114
left a comment
There was a problem hiding this comment.
Re-reviewed the delta pushed after my earlier approval (8bc4fca → 6c2dfdc: commits 7b65258, c27d7d4, 000fdbf).
What I checked
- Quit exemption: matched on the resolved command (
name === 'quit'), so the/exitaltName is covered; the shell discards both mid-turn queues and interrupts the in-flight stream before exiting, with the ordering asserted (popQueue → interrupt → quit). - Idle-safe drain: the
deferredRevisionpush nonce lands a held command whose defer verdict arrives after the idle edge; the drain is gated onstreaming || dialog, anopen_dialogoutcome re-queues the rest behind the dialog, and asubmit_promptoutcome still pauses the queue behind the new turn. - Expansion layer move:
@-mentions now expand inlivePromptEventsgated on provenance (submittedPromptdefined + string prompt), so generated slash payloads stay unexpanded, queued mid-turn text expands as the follow-on turn, attachments ride through untouched, and a declined read reports on the card instead of sending. The steering hop stays raw, disclosed as #8662. - Test changes adapt rather than weaken: the shell-level expansion tests moved to live-session with equivalent decline/failure coverage; e2e.yml size baseline matches the head file byte-for-byte (25891).
No blockers found in the delta. Re-approving (previous approval auto-dismissed by the push). CI on this head is still running; ubuntu result worth confirming before merge.
…lay one submission per turn Round-2 review findings: - R2-1: the drain consulted only streaming/dialog, so the exits that bypass the shell's quit branch (Ctrl+C/Ctrl+D double press, render-error bailout) could not stop an in-flight snapshot from dispatching held commands against a tearing-down session. The drain now reads the shared exit latch at the edge and between dispatches. - R2-2: end-of-turn replay joined every queued mid-turn text into one turn, so the expander's one reachable decline (an abort landing inside the @-mention read) swallowed the whole queue. Replay is now one submission per chained turn, matching ink's pop-one-per-settle drain. - R2-3: the multimodal pass-through test now carries production-shaped options so the gate's string check decides (the provenance short-circuit no longer masks it), and its comment cites the real ink symbol. Each fix is mutation-checked: reverting it turns the added test red.
yiliang114
left a comment
There was a problem hiding this comment.
Reviewed the follow-up commit 07962f5 against the round-2 findings:
- R2-1 (drain vs exit): the drain effect now consults the shared
exitSessionlatch (isExitInProgress()) both at the edge and between dispatches, closing the exits that bypass the shell's quit branch (Ctrl+C/Ctrl+D double press, render-error bailout). Verified the latch:exitSessionis idempotent, sets it before draining cleanup, andstart-opentui-ui.tsxroutes the interrupt exit through it. - R2-2 (queue replay): end-of-turn replay now pops exactly one submission (
[text, ...remaining]) and chains it as a single provenance-carrying turn, matching ink's pop-one-per-settle drain — a decline (e.g. abort landing inside an @-expansion read) can no longer swallow the whole queue. - R2-3: the multimodal pass-through test now carries production-shaped options so the gate's string check decides.
New tests accompany each fix (live-turn/app-shell/live-session). No new blockers in the delta. Re-approving (previous approval auto-dismissed by the push).
Addressed in 07962f5: R2-1 (drain now gated on the shared exit latch at the edge and between dispatches), R2-2 (replay pops one submission per chained turn), R2-3 (multimodal test carries production-shaped options). Dismissing to unblock the updated head.
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Re-reviewed at head 07962f57 (this pass covers the work since my earlier review of the sharding-era batches and my last-round concerns).
- Both gaps I flagged last round are fixed in exactly the right shape and pinned by new tests: the held-command drain now consults the shared exit latch at the effect edge and between dispatches (covering the exit paths that bypass this shell's quit branch), and the end-of-turn replay pops ONE queued submission per settle so a decline inside an
@-expansion consumes only its own message — matching ink's per-settle drain, with the remainder kept for later boundaries. - The earlier round's R2-series threads are all closed — 14/14 overall.
- CI: the one failure on this head is the web-shell
polls an expanded workspace once per 30 s tickPlaywright case (a wall-clock-polling spec that lives inpackages/web-shell, which this PR's cli/opentui-only diff never touches) — an unrelated contention flake worth a follow-up to that suite, not a merge blocker here; per the channel convention the call is on the review itself. - The three standing CHANGES_REQUESTED tickets all predate these fixes; the newest bot review is still running on this head.
Addressed in 7b65258: quit is exempted from the mid-turn defer gate (matched on the resolved command, covering the /exit altName), and the shell interrupts the stream then exits (ordering asserted by test).
|
Round 4 — posted post-merge (the PR merged while this review was running; head/base unchanged, so the verification below applies to exactly what shipped). The one new commit since round 3 ( Ledger vs round 3:
New (round 4, minor, body one-liner per thread budget):
Ran (linux, head Not covered: live-model/Bun leg at this head (leg files don't touch drain/replay paths; the queue-promotion rewrite is unit-pinned only) · Windows N/A · typecheck sandbox caveat unchanged (workspace types resolve against a stale co-clone build). Cross-check: no reviewer filed anything new at this head beyond what's dispositioned above; Verdict: no blocking findings on the shipped head — the three open blockers are fixed with pinned tests (and two human/bot approvals were already recorded on Reviewed with AI assistance. |
PR 10831 — local deep verification (maintainer round)Verdict: 中文摘要结论:
Central claim and the A/BCentral claim. A composer submission under the OpenTUI renderer now (a) carries the raw typed text as Primary A/B — the repo's own restored tests. Same test blobs on both arms (sha256-identical;
Secondary A/B — my 7-cell parity harness (
C1 — approval-mode indicator. Base draws nothing under the composer; head draws
C2 — mid-turn
C5 — C2 is the load-bearing cell: on head the fake server sees exactly one request while the stream is held (the command did not race it), then a second request whose body carries core's Corrections to the PR's description1. The test plan's "path that does not exist" repro does not do what it says. Reviewer Test Plan step 2 instructs pointing an 2. Findings (non-blocking)F1 — under bun, an unreadable F2 — the restored leg's readiness signal is the very text gap #2 fixes. Not covered
Targeted gates
Workflow-side highlights: the restored job block is line-identical, in order, to the 66 lines #10829 removed; the Mutation matrixSuite under mutation: the five opentui unit files the PR touches (160 tests). Each mutation is a single-point edit to one production file;
Nine of the ten new guards are load-bearing against the suite that claims to pin them. The single survivor is classified below; no mutant regressed from killed to survived relative to the control, and the control itself is green, so the kills mean something. F3 — M9 survivor: coverage gap, not dead code (non-blocking). minimal test sketch for the M9 windowConstruct the gateway, do not let the dispatcher-attach effect resolve, set MethodologyTwo detached worktrees ( Local round artifacts (report, harnesses, raw per-cell logs, diagnostics): |
Addendum to the maintainer round above — three things it listed as not covered, plus a mutation refinementA second local rig ran in parallel with the round above (different machine session, different harness, same conclusion). Rather than repeat what it already established, this comment carries only what is new or different: the fourth restored E2E file actually run, wire proof that the compaction took effect, a bundle-level reproduction of the R2-1 quit regression, and a correction to the exit-latch mutation result. Provenance. Tested tree = PR head 1. The fourth restored file ran — and so did the whole leg, on both arms
The AFTER line matches CI run 33642038701 exactly — One honest caveat: 2. The compaction did not just fire — it replaced the historyHolding the first stream open for a fixed 30 s and typing
3. The quit exemption, reproduced at the bundle levelThe round above kills this mutant against the unit suite. I also built it as a shipped-bundle mutant — one line deleted ( 4. Correction: either exit-latch check can be deleted alone and the suite stays greenThe matrix above records
@chiga0's round-4 note flagged the in-loop check as unpinned; the edge check is unpinned too, and that is the one the round-3 thread was actually about. So the follow-up on #8662 should pin both sites, not one. I could not build an E2E probe that separates the shipped build from the both-latches-removed build (queue 5. Queue corners, measured on the wire
And with Smaller confirmations (agreeing with the round above)
Not covered hereWindows. Wire-level proof that a mid-turn Verdict: unchanged — the merged behaviour is what the PR describes. The one thing I would carry forward is §4: pin both exit-latch sites, not just the in-loop one. 中文说明对上一轮维护者验证的补充 —— 它列为"未覆盖"的三项,外加一处变异结论的修正上一轮维护者验证与本轮在同机并行进行(不同会话、不同装置,结论一致)。为避免重复,本条只写新增或不同的部分:真正跑起来的第四个 E2E 文件、压缩"确实生效"的链路证据、R2-1 退出回归在打包产物层面的复现,以及退出闩变异结论的一处修正。 溯源。 测试树 = PR head 1. 第四个恢复文件真正跑了 —— 而且整条腿在两臂都跑了
AFTER 这一行与 CI run 33642038701 完全一致 —— 一点必须如实说明的保留: 2. 压缩不只是"发出去了",而是真的换掉了历史把第一条流固定保持 30 秒、在约 t+4 秒键入
3. quit 豁免,在打包产物层面复现上一轮用单测杀死了这个变异体。我额外把它做成已发布 bundle 的变异体 —— 删掉一行( 4. 修正:两处退出闩检查,任意一处单独删除,套件都保持全绿上面的矩阵记录为
@chiga0 的第 4 轮备注指出循环内那处没被钉住;边沿那处同样没有,而且它才是第 3 轮线程真正讨论的那一处。所以 #8662 上的跟进应当把两处都钉住,而不是只钉一处。我没能构造出让"已合入构建"与"两处闩都删掉的构建"表现不同的 E2E 探针(先排队 5. 队列边角,按链路计时流中先后键入
另外,在 其余确认(与上一轮结论一致)
本轮未覆盖Windows。流中 结论不变:合入后的行为与 PR 描述一致。 唯一值得带走的是 §4:请把两处退出闩都钉住,而不只是循环内那一处。 |
* docs: Design the OpenTUI submit and exit parity batch Six gaps where the ink renderer has a mechanism the OpenTUI port carried only the shadow of: bare quit tokens, the missing client shutdown signal on exit, mid-turn steering that skips the vision bridge and @-expansion, an e2e leg that cannot hold a stream open, and the noflicker scenario's outcome being judged globally. * fix(cli): Let bare quit tokens reach the OpenTUI dispatcher Typing `exit`, `quit`, `:q` or `:wq` in the OpenTUI renderer sent them to the model as prose, and mid-turn they queued behind the stream instead of stopping it. ink rewrites that family to `/quit` ahead of its message queue; the port had the exemption but not the rewrite, so the gate let a quit through only in the one spelling that already looked like a command. * fix(cli): Stop background work before an OpenTUI session exits Every OpenTUI exit drained the cleanup chain without first telling the client that shutdown was imminent, so memory extract, dream and skill review could spawn new agent work during the exit window — the one thing that keeps a process from finishing. ink makes that signal on its quit path; here all three exits share one drain, so the signal belongs to the drain. * fix(cli): Resolve OpenTUI mid-turn steering like an idle prompt Text the composer queues while a turn runs reached the model raw: no `@path` expansion, no read deadline, and no prompt-side vision bridge, so an image could ride as `inlineData` to a model that cannot read it while ink converts it and discloses the egress. Both hops now resolve a steered message the way an idle submission does — expansion under ink's 10 s read timeout, then the bridge — and a turn that dies mid-resolution hands its texts back to the queue instead of losing them. The bridge can also pick a full-turn vision model mid-turn; the rest of the turn stays on it, including the model named in the mapper's own notices, so the override is read per boundary rather than once per turn. Part of U-21 and U-25 in QwenLM#8662. * test(cli): Pin the exit check between queued command dispatches Round four of QwenLM#10831 noted that the deferred-command drain checks the shared exit latch twice but only the pre-loop check had a test: deleting the per-iteration one left the suite green. This adds the crossing that check exists for — the exit begins while an earlier command is still awaiting its outcome — by flipping the latch inside the first dispatch and requiring the second never to run. Part of U-23 in QwenLM#8662. * fix(scripts): Require the offline parity gate's base to fail The offline no-flicker scenario runs a fixture emitter that injects clears and unbalanced DEC 2026 as its base side, yet the runner accepted both-pass as a passing outcome. A base fixture that stopped emitting its defect would have kept the gate green while proving nothing at all. Scenarios can now declare their base a defect fixture with `expectBaseFailure`, which turns both-pass into a failure — set on the offline scenario only, since the credentials scenario compares against ink and the self-test override path asserts both-pass deliberately. A gate failure names its own cause in the report and on the console line. Part of U-15 in QwenLM#8662. * test(integration): Hold the fake model stream open mid-turn Every mid-turn behaviour in the submit path is invisible to a test that cannot say "the CLI is streaming right now": the fake server wrote each SSE chunk synchronously, and awaiting its handler holds a turn only before the first byte. FakeOpenAIResponse gains holdAfterChunks + holdUntil, so a test can stop the stream after a delta it can see on screen and release it on purpose. close() tears held connections down, which is pinned because "the run cannot hang on my instrument" is exactly the claim an instrument like this must not make untested. Four cases use it, one file for both renderers: /quit and a bare `exit` typed into a live stream, a slash command held back mid-turn, and a steered @file mention. The last two assert on captured request bodies rather than screen text, because OpenTUI repaints by diffing cells and renders no slash command output at all. Closes U-24 in QwenLM#8662. * docs: Record the batch-8 measurement boundary and adjacent gaps The design doc said each new case was checked red-then-green before its own fix commit. What was done instead is mutation probes, and two of the four leave the OpenTUI leg green — so replace the claim with the measured table and its cause: a submission that skips the mid-turn gate is steered into the same turn and still reaches the model only after the held turn ends, and process teardown closes the socket however the turn ends. Both halves are pinned at unit level, which is a different claim. Register the three gaps this batch found and deliberately did not fix: steered text has no transcript echo (U-26), neither hop warns about an unsupported image format (U-27), and OpenTUI keeps slash command output in a history no view renders (U-28) — the last is why the first version of the deferral case failed for a reason unrelated to the gate under test. * docs: Correct the mid-turn requeue rule in the batch-8 design The design doc said a timed-out mid-turn read hands its text back to the queue. The code requeues on an abort only — a timeout or a declined read is dropped with a warning, which is what ink does and what the steering test pins — so the sentence described a behaviour the port deliberately does not have. * docs: Measure both of the batch-8 drain's exit-latch checks Deleting the in-loop check reddens exactly the test written for it, and deleting the pre-loop check reddens nothing at unit level. The pair is what makes the documented redundancy a measurement instead of an argument, so both now sit in the coverage boundary alongside the two mutations the E2E leg cannot see. --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
|
Released in v0.23.0. |
















What this PR does
Brings the OpenTUI renderer's submit path in line with the ink renderer in four places: a composer submission now carries the raw text the user typed alongside the content that goes to the model, an
@-mention typed by the user reaches the model as the file content it stands for, the approval-mode status text is actually drawn instead of only computed, and a slash command typed while the model is still responding waits for that turn to end instead of running concurrently with the open stream. The wait exempts the quit family exactly as ink's does, so an exit typed mid-turn still exits — and cancels the turn it is leaving instead of racing it.The held-command queue behaves like ink's message queue in the corners too: a defer verdict that lands after the turn already ended still drains, a command that opens a dialog pauses the replay until the dialog closes, a command that starts a turn parks the commands behind it, and an exit discards both mid-turn queues so nothing replays into the cleanup chain.
It also puts the OpenTUI interactive E2E leg back into
e2e.yml— restoring the job exactly as #10829 removed it — and bumps the workflow size baseline to match, which the size ratchet requires in the same PR.Why it's needed
The OpenTUI interactive E2E leg went red on main, and #10829 took the leg out of CI rather than excluding files, registering its restoration as gated on the parity gaps the leg had surfaced (#8662 U-17…U-20). The gaps are real renderer defects rather than anything the tests assert wrongly, so closing them is what un-gates the leg:
@-mentions typed by the user also reached the model as literal text instead of the file content they stand for, and the expanded payload had nowhere to be distinguished from what the user actually typed./compresstyped while a response was still streaming did nothing whatsoever — no compression, no telemetry, no message. The command ran against a chat the open stream was still writing. ink holds such a command and runs it once the turn ends; only commands that explicitly opt into running mid-stream are dispatched immediately, and ink deliberately exempts its quit family from that wait so an exit can stop a turn.Restoring the leg is the point of the exercise: without it the renderer's submit path has no automated OpenTUI signal at all, and the leg is what caught these. Its own runtime contract was never wrong — the pinned Bun version and the strict renderer selection are exactly what make a silent ink fallback fail the leg instead of passing as a false green — so it comes back byte-for-byte rather than rewritten.
One correction to the record: the four red files trace to four gaps, not the three #10829 listed — the context-compression failure needed its own mid-turn gate. And the same file carries a fifth case, a
/compresssubmission with trailing instructions, that was red on the first two main runs and green on the third; it submits mid-turn too, so it is the same mechanism. Both are verified here rather than assumed, because the restored leg runs the whole directory.Reviewer Test Plan
How to verify
Build and bundle, then drive the CLI under the OpenTUI renderer.
YOLO mode/Accepting edits/Auto mode, localized). Before this change nothing was drawn. Plan and default modes still show no status text, matching ink's prompt.@expansion. With aUserPromptSubmithook that dumps its stdin to a file, submit@somefile.txt inspect this. Expect the hook'spromptfield to contain the file's content,submitted_promptto contain exactly the text that was typed (no file content), and the transcript row to show the typed text rather than the expanded payload. Then make the model call a tool and confirm the continuation's hook input has nosubmitted_promptat all. Three more rules on the same path: a mention that cannot be read (point it at a path that does not exist) settles as a failed tool card in the transcript and the submission is dropped rather than sent unexpanded — before this change the composer cleared and nothing happened, with no trace of why; a slash command's generated payload that happens to contain@…is not expanded (ink'sprocessQueryreturns it before its own mention check); and a mention typed while a turn is still responding is queued like any other mid-turn text — if the queue drains into the running turn as steering it reaches the model as typed (Migrate TUI rendering layer from ink to OpenTUI (tracking) #8662 U-21, a registered divergence), and if it survives to become the next turn it expands like an idle submit, provenance included./compressand press Enter before the response finishes. Expect a notice that the command was queued, then the compression to happen once the response ends (achat_compressiontelemetry event lands). Before this change nothing happened at all./help(or/about,/theme,/tools,/tasks,/docs,/hooks,/lsp) while the model is still responding: it runs immediately, as before./quit(or/exit). Expect the CLI to exit at once with the graceful exit path — the in-flight request is cancelled before the exit drains, so cleanup never races a stream that is still writing, and neither the held-command queue nor the steering queue replays after the exit. Before the mid-turn gate existed this worked; the first cut of the gate queued it, which review caught.?btwside-question still goes straight to the model instead of being queued.main, the nightly schedule and manual dispatch, so a pull request never exercises it. It was dispatched against this branch directly; the results are in the evidence below.Evidence (Before & After)
After — the leg itself, in the environment that was failing. Dispatched four times against this branch as it moved (ubuntu-latest + Bun 1.3.14 + live model). The completed runs:
47f594ae138bc4fcabc2Test Files 8 passed | 1 skipped (9),Tests 14 passed | 2 skipped (16), 87.3s7b6525878a000fdbf164(current head)The same job on the previous three main runs reported
4 failed | 4 passed | 1 skipped, and the four files above were the failures. Strict renderer selection is what makes this a real OpenTUI signal: every spawned CLI on this leg runs under Bun with the renderer pinned andQWEN_TUI_RENDERER_STRICT=1, so a boot that fell back to ink fails the leg.After — locally, on the merged tree (macOS, Bun, OpenTUI, live model for the compression cases), all five cases in one process:
The one skip in that file is a Windows-only exclusion; the two compression cases run on Linux, macOS and CI alike.
Before — the mechanism, reproduced deterministically. A throwaway harness whose fake server keeps the first SSE stream open for 25s after emitting its text: under ink, the mid-stream
/compressis queued and reportsChat history compressed from ~2433 to ~59 tokens.once the stream ends; under OpenTUI the same harness produced no telemetry and no output at all. After this change the OpenTUI run passes the same way ink does, and the queued notice is visible in the terminal capture. The harness was not committed.Supporting checks. The renderer's own unit directory (
packages/cli/src/ui/opentui) passes on the final head — 67 files, 1054 tests — covering the mid-turn gate (deferred until idle, immediate for a command that opted in, quit exempt by resolved command including its/exitaltName, verdicts landing after the idle edge, dialogs pausing the replay, exits discarding both queues), provenance riding the first send of a turn but not its tool-result continuation, the@-mention rules at submit (expanded where the prompt enters the stream for user-typed text, unexpanded for generated payloads, a failed read settling as a failed card instead of silence), and the approval-mode text the composer now draws. Every one of those submit-path pins was checked by reverting the corresponding fix and confirming the test goes red, across all three review rounds.npm run typecheck,npm run build,npm run lintandnpm run bundleclean on the merged tree; the restored workflow parses,npm run test:scriptsand the workflow-size gate pass with the bumped baseline, and Prettier reports no drift in the touched files.The CI unit lane, for the record.
Test (ubuntu-latest, Node 22.x)has failed on this PR twice for non-code reasons: once by hitting the shared-host 120-minute ceiling with tests still making progress (run 33651850433), and once at the workflow-size ratchet after main lowered thee2e.ymlbaseline mid-flight (run 33697494983) — this PR restores the job block that removal shrank, so the baseline is bumped here with the reason in the commit message. On this macOS sandbox the fullpackages/clisuite has 3 red files outside this PR's diff (AuthDialog, tworeviewcommand files), all timeout-shaped; nothing in the diff can reach them — the only inbound import of the touched directory from outside it is a dynamic import inllm.tsxwhose test mocks the touched entry — and the ubuntu lane is the arbiter for the suite as a whole. TheDependency CVE auditred check fails on main's own runs (e.g. 33675732009 at8fde141a24); this PR touches no manifest.Tested on
Environment
npm run build && npm run bundle, then the interactive leg under Bun with the OpenTUI renderer pinned and the sandbox disabled. The compression and provenance cases use a live model; the others use the fake OpenAI server.Risk & Scope
mainthe way it did before. That is the signal fix(ci): Take the OpenTUI interactive e2e leg out of CI #10829 removed and this PR asks for back.resolveSteeredMessages), and reproducing its read-timeout-and-restore half-way was the worse option (U-21). The bare quit tokensexit,quit,:q,:q!,:wq,:wq!are still submitted to the model as prompts; only the slash forms are exempt from the gate (pre-existing, U-22). OpenTUI exit paths do not callLlmClient.requestShutdown()the way ink's quit action does (U-23). No deterministic E2E case exists for a mid-turn/quit; it is pinned by units driving the real built-in (U-24). With a vision bridge configured, an unsupported@-mentioned image now reaches the model unbridged where ink converts it withapplyVisionBridgeIfNeeded— verified during review; porting the prompt-side bridge is a renderer feature addition, deferred rather than half-built (U-25). Also unchanged: the compress command's "already compressing" guard cannot fire because the command context snapshots the pending item. One improvement to note:@-expansion now rides the turn's abort signal — it happens inside the turn, so an Esc during a mention read cancels the submission instead of being ignored, which was not true before this PR.Linked Issues
Part of the ink → OpenTUI renderer migration (#8662) — closes U-17, U-18, U-19 and U-20 there, which is the gate #10829 put on restoring this leg, and registers U-21…U-25 for what it deliberately leaves out. Redesigns nothing from that PR; it reverts its workflow removal once the gate is satisfied.
中文说明
这个 PR 做了什么
把 OpenTUI 渲染器的提交路径与 ink 渲染器在四处对齐:composer 提交时除了送给模型的内容,还会带上用户真正键入的原文;用户键入的
@引用以它所代表的文件内容送给模型,而不再是字面文本;审批模式的状态文字真正被绘制出来,而不只是算出来;在模型仍在响应时键入的斜杠命令会等这一轮结束再执行,而不是和仍在进行的流并发运行。等待机制像 ink 一样豁免 quit 家族:回合进行中键入的退出命令仍然立即退出——并且先取消正在离开的那一轮,而不是和它抢跑。被扣住的命令队列在边角行为上也对齐 ink 的消息队列:门禁的判定结果落在空闲边沿之后仍会继续派发;打开了对话框的命令会把后续重放暂停到对话框关闭;自己开启新一轮的命令会让身后的命令排队等待;退出会把两条流中队列一并丢弃,不让任何东西重放进清理链。
同时把 OpenTUI 交互式 E2E 腿放回
e2e.yml——按 #10829 删除时的原样恢复那个 job——并按体积棘轮的要求在同一 PR 内更新 workflow 体积基线。为什么需要
OpenTUI 交互式 E2E 腿在 main 上变红,#10829 选择把整条腿移出 CI 而不是排除文件,并把恢复条件登记为该腿暴露出的渲染器缺口被关闭(#8662 U-17…U-20)。这些缺口是真实的渲染器缺陷,不是测试断言写错,因此修掉它们正是解锁恢复的路径:
@引用也以字面文本送给模型,而不是它代表的文件内容;展开后的内容与实际键入的文本无处区分。/compress完全没有任何反应——没有压缩、没有遥测、没有提示。命令是对着一个仍被流写入的会话执行的。ink 会把这类命令留到本轮结束后再执行;只有显式声明可以在流中运行的命令才会立即派发,而且 ink 刻意让 quit 家族绕过这个等待,好让退出能叫停一轮。把这条腿恢复回来才是这件事的意义所在:没有它,渲染器的提交路径在 OpenTUI 侧完全没有任何自动化 CI 信号,而正是这条腿发现了这些缺陷。它自身的运行时约定从来没错——固定的 Bun 版本加上严格的渲染器选定,恰恰是让"静默回退到 ink"必须判红而不是假绿的机制——所以它是一字不差地恢复,而不是重写。
对记录的一点更正:四个变红的文件对应四个缺口,而不是 #10829 写的三个——上下文压缩这个失败需要它自己的流中门禁。而且同一个文件还有第五个用例:带尾随指令的
/compress提交,它在头两次 main 运行变红、第三次变绿;它同样是流中提交,因此是同一个机制。这两点都是实测确认的,不是推断,因为恢复后的腿跑的是整个目录。评审测试计划
如何验证
先构建并打包,然后在 OpenTUI 渲染器下驱动 CLI。
YOLO mode/Accepting edits/Auto mode,会本地化)。改动前什么都不画。Plan 与 default 模式仍不显示状态文本,与 ink 的提示符一致。@展开。 配置一个把 stdin 落盘的UserPromptSubmithook,提交@somefile.txt inspect this。期望 hook 的prompt字段包含文件内容,submitted_prompt恰好是键入的文本(不含文件内容),transcript 里显示键入文本而非展开后的内容。随后让模型调用一次工具,确认续跑那次的 hook 输入完全没有submitted_prompt。同一条路径上还有三条规则:指向读不到路径的引用会以一张失败的工具卡片在 transcript 里收场,提交被丢弃而不是把未展开的文本发给模型——改动前 composer 会清空、什么都不发生、不留任何解释;斜杠命令生成的内容里即便出现@…也不展开(ink 的processQuery在自己的引用检查之前就把它返回了);回合进行中键入的引用和其它流中文本一样先进队列——若队列在工具边界被排空为 steering,则按原文送达(Migrate TUI rendering layer from ink to OpenTUI (tracking) #8662 U-21,已登记的分歧点),若存活到成为下一轮,则像空闲提交一样展开且带来源。/compress并回车。期望先看到"已排队"的提示,然后在响应结束后真正执行压缩(落下chat_compression遥测事件)。改动前完全没有任何反应。/help(或/about、/theme、/tools、/tasks、/docs、/hooks、/lsp),仍会立即执行。/quit(或/exit)。期望 CLI 立即走正常退出路径——在途请求先被取消,退出清理不会与仍在写入的流抢跑,被扣住的命令队列与 steering 队列都不会在退出之后重放。流中门禁出现之前本来就是这个行为;门禁的第一版把它排了队,是评审抓住的。?btw侧问仍直接送给模型而不被排队。main、夜间定时和手动触发时运行,所以 pull request 永远不会跑到它。这里直接对本分支做了多次手动触发,结果见下方证据。证据(改动前后)
改动后——在本该失败的环境里跑这条腿本身。 随分支推进共手动触发四次(ubuntu-latest + Bun 1.3.14 + 真实模型)。已完成的运行:
47f594ae138bc4fcabc2Test Files 8 passed | 1 skipped (9),Tests 14 passed | 2 skipped (16),87.3s7b6525878a000fdbf164(当前 head)同样这个 job 在之前三次 main 运行里报的是
4 failed | 4 passed | 1 skipped,上面四个文件就是当时的失败项。严格的渲染器选定保证这是真实的 OpenTUI 信号:这条腿上每个被拉起的 CLI 都在 Bun 下运行、渲染器被钉住且带QWEN_TUI_RENDERER_STRICT=1,因此启动时若回退到 ink 会让这条腿判红。改动后——本地,在合并后的树上(macOS、Bun、OpenTUI,压缩类用例用真实模型),五个用例在同一进程:
该文件里唯一的跳过是 Windows 排除;两个压缩用例在 Linux、macOS 和 CI 上都会执行。
改动前——机制层面的确定性复现。 一次性脚本:假服务器在吐出文本后把第一条 SSE 流保持打开 25 秒。ink 下,流中提交的
/compress被排队,并在流结束后输出Chat history compressed from ~2433 to ~59 tokens.;OpenTUI 下同一脚本既没有遥测也没有任何输出。改动后 OpenTUI 的运行与 ink 一样通过,终端捕获里能看到"已排队"的提示。该脚本未提交。辅助检查。 渲染器自身的单测目录(
packages/cli/src/ui/opentui)在最终 head 上全部通过——67 个文件、1054 条——覆盖流中门禁(延后到空闲、声明可流中执行的命令立即运行、按解析出的命令名豁免 quit(含/exit别名)、判定结果晚于空闲边沿、对话框暂停重放、退出丢弃两条队列)、"来源字段只随一轮的首次请求、不随工具结果续跑"、提交时的@引用规则(用户键入的文本在进入流处展开、生成内容不展开、读取失败以失败卡片收场而不是沉默)、以及 composer 现在绘制的审批模式文本。这些提交路径锚点在三轮评审中都逐一通过"回退对应修复后用例应变红"验证。合并后的树上npm run typecheck、npm run build、npm run lint、npm run bundle均干净;恢复后的 workflow 可正常解析,npm run test:scripts与更新基线后的体积门禁通过,触碰过的文件在 Prettier 下没有格式漂移。CI 单测通道的记录。
Test (ubuntu-latest, Node 22.x)在本 PR 上两次变红均非代码原因:一次是共享宿主机上跑满 120 分钟上限(run 33651850433,测试仍在推进);一次是 main 在中途调低了e2e.yml的体积基线导致棘轮触发(run 33697494983)——本 PR 恢复的正是当初被删除而使文件变小的 job 块,因此在同一 PR 内更新基线并在提交说明里写明原因。在本机 macOS 沙箱上,packages/cli完整套件有 3 个红文件且都在本 PR 触碰范围之外(AuthDialog、两个review命令文件),全部是超时形态;本 diff 够不到它们——触碰目录在目录外唯一的入向引用是llm.tsx里的一处动态 import,而它的测试直接 mock 了被触碰的入口——整套件以 ubuntu 通道为准。Dependency CVE audit这条红在 main 自己的运行上也红(如8fde141a24上的 33675732009);本 PR 不碰任何 manifest。测试环境
环境
npm run build && npm run bundle,然后在 Bun 下钉住 OpenTUI 渲染器、关闭沙箱运行交互式测试腿。压缩与来源类用例使用真实模型,其余使用假的 OpenAI 服务器。风险与范围
main上失败。这正是 fix(ci): Take the OpenTUI interactive e2e leg out of CI #10829 拿掉、而本 PR 请求换回来的信号。resolveSteeredMessages),但在红腿压力下只复刻它一半的超时加重试是更差的选择(U-21)。裸退出词exit、quit、:q、:q!、:wq、:wq!仍作为提示词发给模型,门禁只豁免斜杠形式(先前就存在,U-22)。OpenTUI 的退出路径不像 ink 的 quit action 那样调用LlmClient.requestShutdown()(U-23)。流中/quit没有确定性的 E2E 用例,由驱动真实内建命令的单测锚定(U-24)。配置了 vision bridge 时,不受支持的@引用图片现在会未经桥接地送达模型,而 ink 会用applyVisionBridgeIfNeeded转换——评审中已核实;移植 prompt 侧桥接是一项渲染器功能新增,刻意延后而不是半途搭建(U-25)。同样未改动:compress 命令的"正在压缩"守卫无法触发,因为命令上下文对 pending item 取的是快照。一点改进值得说明:@展开现在搭乘回合的 abort 信号——展开发生在回合内部,因此提及读取期间的 Esc 会取消这次提交而不是被无视,这在改动之前是不成立的。关联 Issue
属于 ink → OpenTUI 渲染器迁移(#8662)的一部分——关闭其中的 U-17、U-18、U-19、U-20(正是 #10829 为恢复本条腿设定的门禁),并登记 U-21…U-25 作为刻意留下的部分。它不重写那个 PR 的任何设计,只是在门禁满足后撤销它对 workflow 的删除。