fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883)

* 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 #8662.

* test(cli): Pin the exit check between queued command dispatches

Round four of #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 #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 #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 #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 <[email protected]>
This commit is contained in:
ChiGaoandShaojin Wen authored and GitHub committed 2026-09-03 09:31:05 +00:00
1 parent 5bd9091e6b
commit a2ed9928bb
20 files changed
+1785 -136

No files matched your search

@@ -0,0 +1,280 @@
# Batch 8 — OpenTUI submit/exit semantics and mid-turn coverage
Closes U-15, U-21, U-22, U-23, U-24 and U-25 from #8662. Planned in
<https://github.com/QwenLM/qwen-code/issues/8662#issuecomment-5519887822>. Registers three
gaps it deliberately leaves unfixed: U-26, U-27 and U-28.
## What this batch is about
#10831 put the OpenTUI interactive E2E leg back and closed the four gaps that leg
exposed. Everything left in the submit/exit area shares one property: **ink has the
mechanism, and the port carried the shadow of it.** The composer accepts input, the
turn runs, the transcript looks right — and one semantics hop is missing underneath.
That class of gap is invisible to the current leg precisely because no case exercises
it mid-turn, so this batch ships the instrument and the fixes together.
Ink's mid-turn pipeline is one function:
`use-llm-stream.ts` `resolveSteeredMessages` resolves the steered hop — `@` mentions
with a read timeout and a queue restore, then the prompt-side vision bridge, then a
format-support warning — and `prepareQueryForLlm` runs the same bridge on the fresh
hop. OpenTUI's counterparts are `live-session.ts` `expandAtMentions` (fresh hop only,
no bridge) and the raw `drainSteering` push (steering hop, nothing).
## Gap by gap
### U-22 — bare quit tokens are submitted to the model
ink checks the raw submission against
`['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!']` in
`AppContainer.handleFinalSubmit` and routes it to `/quit` **ahead of** reminders and
the message queue. OpenTUI only recognized the slash forms, so `exit` mid-turn became
a prompt.
Fix in the shell's `onSubmit`, before the mid-turn gate and before dispatch: a pure
`normalizeQuitSubmission(text)` in `slash-gateway.ts` maps every token in ink's list to
`/quit`, exactly as ink compares (trimmed, case-sensitive). Normalizing before the gate
matters — a quit must never be deferred to idle.
### U-23 — exits never signal the client to stop background work
`GeminiClient.requestShutdown()` sets `shutdownRequested` and cancels the pending memory
prefetch, so extract/dream/skill-review work is not spawned during the exit window. ink
calls it in its quit action. OpenTUI never calls it anywhere, so every OpenTUI exit
(quit, Ctrl+C/Ctrl+D double press, render-error bailout) can spawn agent work while the
process drains cleanup.
Fix inside `exitSession()` rather than in one handler: the three exits are three call
sites of one drain, and the ledger's own reading is right — this belongs to the drain.
`exitSession(config, code)` calls `config.getLlmClient()?.requestShutdown()` before
`await runExitCleanup()`.
**Deliberate over-parity.** ink calls it on the quit path only, not on its Ctrl+C exit.
The signal means "this process is going down", which is true of all three exits, and
gating it to one handler would reproduce the shape of the bug. Recorded here so the
difference is not read as an accident.
### Folded in from #10831 review (R4-1) — pinning the in-loop exit latch
The deferred-command drain in `opentui-app-shell.tsx` checks `isExitInProgress()` twice:
once at the effect edge, and once per iteration before each `gateway.dispatch`, because
the exit can start while an earlier command is still awaiting its outcome. #10831's
round-4 review noted that only the edge check was pinned — deleting the in-loop check
left the suite green.
New shell test: two commands are queued mid-turn, the fake dispatcher flips the exit latch
_inside_ the first command's handler, and the assertion is that the second never runs. The
drain is already past its edge check when the latch flips, so the in-loop check is the only
thing that can keep the second command back.
### U-21 — steering text rides raw
Text drained at the sampling boundary is pushed as `{ text }` with no `@path`
expansion, while ink expands that hop.
Fix: the boundary drain stops being a synchronous `string[] → responseParts` push.
`live-session.ts` gains `resolveSteeredPromptParts(config, texts, signal, emit)`, the
port of ink's `resolveSteeredMessages`, called from the boundary loop in
`livePromptEvents` so the read cards can be yielded as events (a callback cannot yield).
Per message: `@` expansion through the same `expandAtMentions` the fresh hop uses, under a
10 s timeout. Only an abort requeues — that message and the ones behind it go back through a
new `restoreSteering` seam (ink's `midTurnRestoreRef`) so nothing the turn never sent is
lost. A timed-out or declined read is dropped with a warning instead, as ink drops it: the
failed read is that message's problem, not the turn's, and requeueing it would retry forever.
Segments are joined with a blank-line separator as ink does.
Not ported, on purpose: ink's `GOAL_COMMAND_RE` slash interception, its two-phase
`accept()` recording, and the `checkImageFormatsSupport` warning the steered hop adds
after the bridge. OpenTUI has no goal-permit seam at that boundary, and its fresh hop
already records through `handleAtCommand`; cloning the transaction would import a
mechanism the renderer does not have. The format warning is a different case: OpenTUI's
fresh hop never had it either, so adding it to the steering hop alone would make the two
hops disagree about the same parts. It stays a renderer-wide gap, registered as U-27.
### U-25 — no prompt-side vision bridge
With a vision bridge configured and a primary model that cannot take images, an image
reaching the model as raw `inlineData` should be converted, with an egress notice. ink
runs `applyVisionBridgeIfNeeded` on both hops; OpenTUI has only the tool-result side
(`event-adapter.ts` renders `visionBridgeNotice`), so the transcript can display a notice
that nothing on the prompt side produces.
Fix: `applyPromptVisionBridge` in `live-session.ts`, called on both hops. Shape follows
the existing non-ink port — `Session.#applyBridgeConversionsIfNeeded` — not ink's hook:
`hasImageParts` + `shouldRunVisionBridge` gate, the agent-capable full-turn branch, then
`runVisionBridge`, notices as neutral events, and on a non-applied result
`splitImageParts(...).nonImageParts` so images are never forwarded to a text-only model.
The full-turn branch needs a per-turn model override, which OpenTUI carries as
`LivePromptOptions.modelOverride` read once per turn. `livePromptEvents` keeps it in a
local now, so the bridge can set `getFullTurnVisionModelSelector(...)` for the rest of the
turn and the mapper's model name follows. Gates map one-to-one: a `submit_prompt`
override is ink's _inline_ override (the outcome is produced by the same code path that
sets `isInline: true`), so an active override skips the bridge, and the bridge's own pick
skips re-bridging at later boundaries.
That "for the rest of the turn" is a per-boundary read, and a pick made at the steering
boundary is the only case that can tell: a test where the image arrives on the composer
already has the selector on both sends. So the pin routes an image in through a steered
`@` mention and checks the first send carries no override while the continuation carries
the selector — and names the vision model in its own notice, which the event mapper can
only do by reading the override again.
### U-24 — the leg cannot prove any of it
`integration-tests/fake-openai-server.ts` writes every SSE chunk synchronously, so a
test cannot be sure the CLI is _mid-stream_ when it types. The handler is awaited before
the first byte, which only holds a turn pre-first-byte — the CLI is "thinking", not
streaming, and the mid-turn path a test needs is a different path.
Add `holdAfterChunks: number` + `holdUntil: Promise<void>` to `FakeOpenAIResponse`
(message-level, like `disconnectAfterContentChunks`), making `writeStreamed` async and
awaiting `holdUntil` after that many content deltas. The test owns the promise, so the
release is explicit and every case releases it in its own path. A forgotten release is
degrading, not fatal: `close()` already tears held connections down — pinned by a unit
test, since "the run cannot hang on my instrument" is exactly the claim an instrument
like this must not make untested.
New spec `integration-tests/interactive/mid-turn-submit-interactive.test.ts`, one file for
both legs (`e2e-interactive-opentui` runs the whole `interactive` directory except
`cron-interactive`):
| Case | Pins |
| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `/quit` while a held stream is mid-turn → process exits, the held content never appears | U-24 (the mid-turn exit path itself) |
| bare `exit` while mid-turn → same exit | U-22 — ink passes today, OpenTUI only after this batch, so the case is differential by construction |
| a custom `/defer-probe` typed mid-turn → its expanded prompt reaches the model only after the release | the drain behind #10831's gate, end to end (not the gate's verdict — below) |
| `@notes.txt …` while mid-turn, then a tool call → the next request body carries the file content | U-21 + U-25's expansion hop |
The last two assert on captured request bodies rather than on the screen, and that is a
decision the leg forced instead of a style preference. One file runs under **both**
renderers, and OpenTUI repaints by diffing cells, so text a user can plainly see may never
reach the byte stream as a contiguous run — a positive raw-output wait is the wrong
instrument there. Batch 8 found the harder half of that sentence the hard way: OpenTUI does
not render slash command output at all (U-28, below). The first version of the
deferral case watched for `Approval mode set to`, which ink prints and OpenTUI never does, for
a reason unrelated to the gate under test. Model traffic is the channel both renderers share
and neither can fake: after the release, the held command arrives as a second request whose
body is the prompt expanded from a project command file — text no other submission path
emits, and a request the case counts, so a command that ran twice or as raw typed text is
still visible. What that channel cannot show is the ordering _while_ the stream is held; the
probe result is in "Coverage boundary".
Both boot helpers pin `QWEN_CODE_LANG=en` for the child process. The CLI inherits the
developer's language, so every English UI string a readiness wait looks for is a locale
assertion; on a Chinese settings file the entire interactive suite fails as "CLI did not start
up in interactive mode".
U-23 and the bridge's conversion half stay unit-pinned with mutation checks — background
memory tasks and a real vision-bridge model are not observable through a fake
chat-completions endpoint, and pretending otherwise would add a fake for the sake of a green
line.
### U-15 — the offline gate accepts a base that does not fail
`scripts/tui-parity/runner.mjs` counts both `base-fails-fixed-passes` and `both-pass` as
passing. In the offline no-flicker scenario the base side is a fixture emitter that
injects clears and unbalanced DEC 2026, so `both-pass` there means the fixture lost its
defect — the gate would keep reporting pass while quietly proving nothing.
Fix per scenario, not globally: an `expectBaseFailure` flag in the scenario schema, honoured
by `harnessPass`, set on `opentui-noflicker-offline` only. Two scenarios must keep
tolerating `both-pass`, and for a reason worth stating: `opentui-noflicker` (with
credentials) uses **ink** as its base side, where both-pass is the parity result the
scenario exists to measure; and the `self-test` override path runs identical emitter
argvs on both sides and asserts `both-pass` on purpose. A global tightening would have
broken the instrument in order to fix one fixture.
A gate failure has to name its own cause: `both-pass` under `expectBaseFailure` adds a
`Gate:` line to `report.md` and appends the reason to the scenario's console line, so a
red run reads as "the base fixture emitted no defect" rather than an unexplained outcome
mismatch.
## Known adjacent gaps, not fixed here
### U-26 — steered text has no transcript echo
ink's `accept()` adds a `USER` item with `sentToModel: false` when the steered message
lands; OpenTUI's queue shows a count and the drained text disappears. Registered as U-26
rather than silently widening this diff.
### U-27 — no unsupported-image-format warning on either hop
ink calls `checkImageFormatsSupport` after building the request parts on both the fresh hop
and the steering hop, and adds an INFO row naming the formats the model cannot read. OpenTUI
has no equivalent on either hop, so a `@file` that expands to, say, a TIFF reaches the model
with no disclosure. Registered as U-27 — porting it belongs to one change across both hops,
not to the steering hop only.
### U-28 — OpenTUI renders no slash command output
Found while writing the deferral case, and it is a product gap, not a measurement one. The
backend keeps two histories. `OpenTuiAppHost.addItem` appends a command's invocation echo and
every `info`/`warning`/`error` it returns to `host.history`, and no view reads that array:
`renderMain` draws the live-turn transcript, and `getHistory()` has two readers — the command
context and the composer's history list — neither of which renders. So a command runs, takes
effect, and never says so. The "Queued …" notice does show, because `notify()` writes a
separate slot — which is exactly what made the gap readable as a broken test.
`item-projection.ts` already converts special history items to display text and is imported
only by its own test, so the bridge exists unfitted. Wiring it is a rendering change with its
own ordering question (command output has to interleave with the live transcript rather than
stack above or below it) and it needs the `HistoryItem` kinds enumerated rather than
falling through silently. Registered as U-28: a different seam than submission and exit.
## Verification plan
- Units, mutation-checked per behaviour: `normalizeQuitSubmission`, `exitSession` calling
`requestShutdown` (including that a second exit cannot re-arm it), the steering hop's
expand/restore/timeout, and each bridge gate (skip on override, skip on the bridge's own
pick, strip images when not applied, notice on egress-after-cancel).
- `scripts/tui-parity` self-test + runner tests for `expectBaseFailure`, including that a
`both-pass` run of the offline scenario exits non-zero.
- `npm run build && npm run typecheck`; `packages/cli` vitest for the touched files.
- Both E2E legs locally, then on CI as the backstop. The interactive legs need
`npm run build && npm run bundle` first; the OpenTUI leg additionally needs `bun` on PATH
(1.3.14 locally), so both renderers are covered here rather than only in CI. The PR says
which hop was proven where.
## Coverage boundary
A new instrument has to show it can fail, so each mechanism behind the four cases was
removed in turn, the CLI re-bundled, and the whole spec run on the OpenTUI leg with
`--retry=0`, then restored. Every mutation ran again against
`opentui-app-shell.test.tsx` and `slash-gateway.test.ts` from source, plus the drain's two
exit-latch checks, for which no case in the leg ever reaches an exit. So both columns below
are measurements rather than assumptions.
| Mutation | OpenTUI leg | Units |
| -------------------------------------------- | -------------------------- | ------------------------- |
| drain returns before replaying held commands | red — the deferral case | red — 6 cases |
| bare quit token is not rewritten | red — the bare-`exit` case | red — 9 cases |
| mid-turn gate always answers "run now" | **green** | red — 9 cases |
| quit branch drops the interrupt | **green** | red — 2 cases |
| drain drops its in-loop exit-latch check | not probed | red — the R4-1 case, only |
| drain drops its pre-loop exit-latch check | not probed | **green** — see below |
Those two green cells in the leg column are its real limit, and they share a cause: both
mechanisms change _when_ something happens inside a turn the fake server keeps open, and
every observable this file has is a request the CLI sent. Remove the gate and the command
submits mid-turn — but a mid-turn submit in OpenTUI _is_ a steer, so its expanded prompt
still reaches the model only after the held turn ends, and the request log is unchanged.
Remove the interrupt and the process still exits: teardown closes the socket however the turn
ends. What the deferral case does pin is the drain itself, plus that the command ran exactly
once, after the turn, as an expanded prompt rather than as typed text.
U-23's shutdown path is unit-only by construction, not by omission: the exit cases set
`enableManagedAutoMemory: false`, so no background task exists for `requestShutdown` to wait
on. The `@`-steering case reads a small text file; the vision bridge's conversion half needs a
real vision-capable provider and is covered by no E2E here.
Only the bare-`exit` case is differential — ink passes it before this batch and OpenTUI just
after. On the other three an ink pass shows the instrument is renderer-neutral; U-21, U-22 and
U-23 live in OpenTUI-only code, so the OpenTUI leg is the only one that can redden for them.
The two latch probes measure the redundancy the drain lives with. Deleting the _in-loop_ check
reddens exactly one case — the R4-1 test, which flips the latch inside an in-flight dispatch
precisely because that is the crossing the pre-loop check cannot see; the test is the only thing
pinning that line. Deleting the _pre-loop_ check leaves the unit suite green, because the in-loop
check refuses every command the edge check would have stopped — so no unit test covers the edge
check on its own, and the redundancy is why that is acceptable. Neither latch mutation was
probed against the leg.
@@ -126,6 +126,83 @@ describe('fake OpenAI server', () => {
);
});
it('holds a streamed response until the caller releases it', async () => {
let release!: () => void;
server = await startFakeOpenAIServer(() => ({
contentChunks: ['HELD_FIRST_DELTA', 'HELD_SECOND_DELTA'],
holdAfterChunks: 1,
holdUntil: new Promise<void>((resolve) => {
release = resolve;
}),
}));
const response = await fetch(`${server.baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: 'fake-model',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const first = decoder.decode((await reader.read()).value);
expect(first).toContain('HELD_FIRST_DELTA');
// The unresolved read is the assertion — and it has to survive the timeout,
// since a read the race abandons still consumes the chunk when it lands.
const pending = reader.read();
const arrived = await Promise.race([
pending.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 250)),
]);
expect(
arrived,
'Nothing may reach the client while the stream is held',
).toBe(false);
release();
let rest = '';
let result = await pending;
while (!result.done) {
rest += decoder.decode(result.value, { stream: true });
result = await reader.read();
}
expect(rest).toContain('HELD_SECOND_DELTA');
expect(rest).toContain('data: [DONE]');
});
it('closes while a held response stays unreleased', async () => {
server = await startFakeOpenAIServer(() => ({
contentChunks: ['UNRELEASED_DELTA'],
holdAfterChunks: 1,
holdUntil: new Promise<void>(() => {}),
}));
const response = await fetch(`${server.baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: 'fake-model',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
expect(decoder.decode((await reader.read()).value)).toContain(
'UNRELEASED_DELTA',
);
// A case that forgets to release must not be able to keep the server (and
// so the run) open: close() tears the held connection down under it.
await expect(server.close()).resolves.toBeUndefined();
server = undefined;
const torn = await reader.read().catch(() => ({ done: true as const }));
expect(torn.done).toBe(true);
});
it('can close response connections for isolated latency measurements', async () => {
server = await startFakeOpenAIServer(
() => ({ content: 'no connection reuse' }),
+21 -5
View File
@@ -31,6 +31,14 @@ export type FakeOpenAIResponse = {
contentChunks?: string[];
errorContent?: string;
disconnectAfterContentChunks?: number;
/**
* Streaming-only: pause the response once this many content deltas have been
* written across the whole response, and resume when `holdUntil` resolves.
* Awaiting the handler only holds a turn before the first byte; a test that
* needs the CLI genuinely mid-stream has to stop the stream itself.
*/
holdAfterChunks?: number;
holdUntil?: Promise<void>;
toolCalls?: FakeOpenAIToolCall[];
finishReason?: 'stop' | 'tool_calls' | 'length';
choices?: FakeOpenAIChoice[];
@@ -131,7 +139,7 @@ export async function startFakeOpenAIServer(
const response = await handler({ body, requestIndex });
if (body['stream'] === true) {
writeStreamed(
await writeStreamed(
res,
getModel(body),
response,
@@ -271,12 +279,12 @@ function writeNonStreamed(
);
}
function writeStreamed(
async function writeStreamed(
res: ServerResponse,
model: string,
message: FakeOpenAIResponse,
keepAlive: boolean,
): void {
): Promise<void> {
res.writeHead(200, {
'cache-control': 'no-cache',
connection: keepAlive ? 'keep-alive' : 'close',
@@ -303,6 +311,14 @@ function writeStreamed(
res.write(`data: ${JSON.stringify(payload)}\n\n`, callback);
};
const holdAfterChunks = message.holdAfterChunks ?? -1;
let contentDeltas = 0;
const sendContent = async (choiceIndex: number, content: string) => {
send(chunk(choiceIndex, { content }));
contentDeltas += 1;
if (contentDeltas === holdAfterChunks) await message.holdUntil;
};
const choices = responseChoices(message);
for (const [choicePosition, choice] of choices.entries()) {
send(chunk(choice.index, { role: 'assistant' }));
@@ -319,10 +335,10 @@ function writeStreamed(
send(chunk(choice.index, { content }), () => res.destroy());
return;
}
send(chunk(choice.index, { content }));
await sendContent(choice.index, content);
}
if (!choice.contentChunks && choice.content) {
send(chunk(choice.index, { content: choice.content }));
await sendContent(choice.index, choice.content);
}
for (const [toolIndex, toolCall] of (choice.toolCalls ?? []).entries()) {
send(
@@ -0,0 +1,321 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import stripAnsi from 'strip-ansi';
import {
fakeToolCall,
startFakeOpenAIServer,
type FakeOpenAIHandler,
type FakeOpenAIResponse,
type FakeOpenAIServer,
} from '../fake-openai-server.js';
import {
TestRig,
type,
printDebugInfo,
applyContainerSandboxNoProxy,
fakeServerHostOptions,
} from '../test-helper.js';
type RigSession = ReturnType<TestRig['runInteractive']>;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Rendered by the CLI while the model stream is deliberately held open. Seeing
* it is what proves the run is mid-stream: awaiting the fake server's handler
* only ever holds a turn before the first byte, and a pre-first-byte turn is a
* different CLI state from the one every case below submits into.
*/
const HELD_MARKER = 'MID_TURN_HELD_MARKER';
const HELD_TAIL = 'MID_TURN_HELD_TAIL';
const NOTES_CANARY = 'MID_TURN_NOTES_CANARY_4821';
const TOOL_RESULT_CANARY = 'MID_TURN_TOOL_RESULT_CANARY_4822';
const STEER_PROMPT = 'Also fold in the notes file';
const STEER_TEXT = `@notes.txt ${STEER_PROMPT}`;
/** A project command's prompt body: proof the command ran, nothing else emits it. */
const DEFERRED_CANARY = 'MID_TURN_DEFERRED_COMMAND_CANARY_4823';
const DEFERRED_INVOCATION = '/defer-probe fold the readiness hop in';
let releaseHeldTurn: (() => void) | undefined;
/**
* First-turn response that stops after one content delta and waits for the
* test to release it.
*/
function heldFirstTurn(extra: Partial<FakeOpenAIResponse> = {}): {
response: FakeOpenAIResponse;
release: () => void;
} {
let release!: () => void;
const holdUntil = new Promise<void>((resolve) => {
release = () => {
releaseHeldTurn = undefined;
resolve();
};
});
releaseHeldTurn = release;
return {
release,
response: {
...extra,
contentChunks: [HELD_MARKER, HELD_TAIL],
holdAfterChunks: 1,
holdUntil,
},
};
}
describe('Mid-turn submit', () => {
let rig: TestRig;
let fakeServer: FakeOpenAIServer | undefined;
let restoreNoProxy: () => void;
beforeEach(() => {
rig = new TestRig();
restoreNoProxy = applyContainerSandboxNoProxy();
});
afterEach(async () => {
releaseHeldTurn?.();
releaseHeldTurn = undefined;
await fakeServer?.close();
fakeServer = undefined;
restoreNoProxy();
await rig.cleanup();
});
async function rigSetup(dirName: string): Promise<void> {
await rig.setup(dirName, {
settings: {
memory: {
enableManagedAutoMemory: false,
enableManagedAutoDream: false,
},
ui: {
enableFollowupSuggestions: false,
},
security: {
auth: {
selectedType: 'openai',
},
},
},
});
}
/**
* Starts the scripted server and pins the language the CLI child renders in:
* every readiness and behaviour match below is an English UI string, and both
* boot helpers spawn from `process.env` with no per-run override (the cron
* suite pins the same variable for its sessions).
*/
async function startHeldServer(
handler: FakeOpenAIHandler,
): Promise<FakeOpenAIServer> {
process.env['QWEN_CODE_LANG'] = 'en';
fakeServer = await startFakeOpenAIServer(handler, fakeServerHostOptions());
return fakeServer;
}
function cliArgs(server: FakeOpenAIServer): string[] {
return [
'--auth-type',
'openai',
'--openai-api-key',
'fake-key',
'--openai-base-url',
server.baseUrl,
'--model',
'fake-model',
];
}
async function bootCli(handler: FakeOpenAIHandler): Promise<RigSession> {
const server = await startHeldServer(handler);
const session = rig.runInteractive(...cliArgs(server));
const isReady = await rig.waitForText('Type your message', 30_000);
if (!isReady) {
printDebugInfo(rig, rig._interactiveOutput, { isReady });
}
expect(isReady, 'CLI did not start up in interactive mode').toBe(true);
return session;
}
/**
* Opens a turn and waits until the held delta is on screen — the state every
* case submits into.
*/
async function submitUntilMidTurn({ ptyProcess }: RigSession) {
await type(ptyProcess, 'Start the review.');
await type(ptyProcess, '\r');
const midTurn = await rig.waitForText(HELD_MARKER, 30_000);
if (!midTurn) {
printDebugInfo(rig, rig._interactiveOutput, { midTurn });
}
expect(
midTurn,
'Held response never reached the screen, so the turn is not mid-stream',
).toBe(true);
}
/**
* Everything the CLI has sent the model so far, as one haystack. Whether a
* submission ran is read from request bodies rather than screen text: OpenTUI
* repaints by diffing cells, so text a user can plainly see may never reach
* the byte stream as a contiguous run.
*/
function requestBodies(): string {
return fakeServer!.requests
.map((request) => JSON.stringify(request.body))
.join('\n');
}
async function waitForRequestBody(fragment: string): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < 30_000) {
if (requestBodies().includes(fragment)) return true;
await sleep(200);
}
return false;
}
async function expectExitMidHold(
{ promise }: RigSession,
label: string,
): Promise<void> {
const exited = await Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000)),
]);
if (!exited) {
printDebugInfo(rig, rig._interactiveOutput, { exited });
}
expect(exited, `${label} did not exit while the stream was held`).toEqual(
expect.objectContaining({ exitCode: 0 }),
);
}
it('exits on /quit while the response stream is held mid-turn', async () => {
await rigSetup('mid-turn-quit');
const held = heldFirstTurn();
const session = await bootCli(() => held.response);
await submitUntilMidTurn(session);
await type(session.ptyProcess, '/quit');
await type(session.ptyProcess, '\r');
await expectExitMidHold(session, '/quit');
expect(stripAnsi(rig._interactiveOutput)).not.toContain(HELD_TAIL);
held.release();
});
it('exits on a bare quit token while the response stream is held mid-turn', async () => {
await rigSetup('mid-turn-bare-exit');
const held = heldFirstTurn();
const session = await bootCli(() => held.response);
await submitUntilMidTurn(session);
// No leading slash: `exit` has to be recognised as a quit before it can
// reach the model as prompt text.
await type(session.ptyProcess, 'exit');
await type(session.ptyProcess, '\r');
await expectExitMidHold(session, 'bare exit');
expect(fakeServer!.requests).toHaveLength(1);
expect(stripAnsi(rig._interactiveOutput)).not.toContain(HELD_TAIL);
held.release();
});
it('holds a slash command back mid-turn and runs it once the stream ends', async () => {
await rigSetup('mid-turn-deferred-command');
rig.mkdir('.qwen/commands');
rig.createFile(
'.qwen/commands/defer-probe.md',
`---\ndescription: Deferred command probe\n---\n${DEFERRED_CANARY} {{args}}\n`,
);
const held = heldFirstTurn();
const session = await bootCli(({ requestIndex }) =>
requestIndex === 0 ? held.response : { content: 'MID_TURN_DEFER_DONE' },
);
await submitUntilMidTurn(session);
await type(session.ptyProcess, DEFERRED_INVOCATION);
await type(session.ptyProcess, '\r');
// No second request may open while the held turn is still in flight. This
// is not the gate: a submission that skipped it would be steered into the
// same turn and would leave this assertion untouched too (measured). The
// gate's verdict is pinned in the app-shell units; what this case adds is
// the drain half below.
await sleep(3000);
expect(
requestBodies(),
'A second request opened while the held turn was still in flight',
).not.toContain(DEFERRED_CANARY);
held.release();
const drained = await waitForRequestBody(DEFERRED_CANARY);
if (!drained) {
printDebugInfo(rig, rig._interactiveOutput, { drained });
}
expect(drained, 'The held-back slash command never ran').toBe(true);
// It ran as a command: the prompt body the registry expands from the command
// file, in a single extra turn, never the typed invocation as prompt text.
expect(fakeServer!.requests).toHaveLength(2);
expect(JSON.stringify(fakeServer!.requests[1].body)).toContain(
'fold the readiness hop in',
);
expect(requestBodies()).not.toContain(DEFERRED_INVOCATION);
session.ptyProcess.kill();
await session.promise;
});
it('expands a steered @file mention into the tool continuation request', async () => {
await rigSetup('mid-turn-steered-file');
const toolFile = rig.createFile('tool-result.txt', TOOL_RESULT_CANARY);
rig.createFile('notes.txt', NOTES_CANARY);
const held = heldFirstTurn({
toolCalls: [fakeToolCall('read_file', { file_path: toolFile })],
});
const session = await bootCli(({ requestIndex }) =>
requestIndex === 0 ? held.response : { content: 'MID_TURN_STEER_DONE' },
);
await submitUntilMidTurn(session);
// Typed while the first turn still streams: this mention can only reach the
// model through the steering hop, never through the composer.
await type(session.ptyProcess, STEER_TEXT);
await type(session.ptyProcess, '\r');
held.release();
const done = await rig.waitForText('MID_TURN_STEER_DONE', 30_000);
if (!done) {
printDebugInfo(rig, rig._interactiveOutput, { done });
}
expect(done, 'Tool continuation never completed').toBe(true);
expect(fakeServer!.requests).toHaveLength(2);
const [firstBody, continuationBody] = fakeServer!.requests.map((request) =>
JSON.stringify(request.body),
);
expect(
firstBody,
'The steered mention leaked into the request that opened the turn',
).not.toContain(NOTES_CANARY);
expect(continuationBody).toContain(TOOL_RESULT_CANARY);
expect(continuationBody).toContain(STEER_PROMPT);
expect(
continuationBody,
'Steered @file reached the model unexpanded, so its content never did',
).toContain(NOTES_CANARY);
session.ptyProcess.kill();
await session.promise;
});
});
@@ -13,6 +13,7 @@ import {
afterEach,
type MockInstance,
} from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import {
registerCleanup,
_resetCleanupFunctionsForTest,
@@ -38,10 +39,16 @@ describe('exitSession', () => {
let exitSpy: MockInstance<
(code?: string | number | null | undefined) => never
>;
let requestShutdown: MockInstance<() => void>;
let config: Config;
beforeEach(() => {
_resetExitLifecycleForTest();
_resetCleanupFunctionsForTest();
requestShutdown = vi.fn();
config = {
getLlmClient: () => ({ requestShutdown }),
} as unknown as Config;
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new ExitCalled(code);
});
@@ -62,11 +69,32 @@ describe('exitSession', () => {
order.push('second');
});
await expect(exitSession(EXIT_CODE_INTERRUPT)).rejects.toThrow(ExitCalled);
await expect(exitSession(config, EXIT_CODE_INTERRUPT)).rejects.toThrow(
ExitCalled,
);
expect(order).toEqual(['first', 'second']);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_INTERRUPT);
});
it('signals the client before the drain so no work spawns mid-exit', async () => {
const order: string[] = [];
requestShutdown.mockImplementation(() => {
order.push('shutdown');
});
registerCleanup(() => {
order.push('cleanup');
});
await expect(exitSession(config, 0)).rejects.toThrow(ExitCalled);
expect(order).toEqual(['shutdown', 'cleanup']);
});
it('still exits when there is no client to signal', async () => {
config = { getLlmClient: () => undefined } as unknown as Config;
await expect(exitSession(config, 1)).rejects.toThrow(ExitCalled);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('uses signal-style exit codes', () => {
expect(EXIT_CODE_INTERRUPT).toBe(130);
expect(EXIT_CODE_TERMINATED).toBe(143);
@@ -76,18 +104,19 @@ describe('exitSession', () => {
const cleanup = vi.fn();
registerCleanup(cleanup);
await expect(exitSession(0)).rejects.toThrow(ExitCalled);
await expect(exitSession(config, 0)).rejects.toThrow(ExitCalled);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(isExitInProgress()).toBe(true);
// The second call returns a pending promise and must not re-run cleanup
// or exit again.
const second = exitSession(0);
// The second call returns a pending promise and must not re-run cleanup,
// re-signal the client, or exit again.
const second = exitSession(config, 0);
await Promise.race([
second,
new Promise((resolve) => setTimeout(resolve, 20)),
]);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(requestShutdown).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledTimes(1);
});
@@ -95,7 +124,7 @@ describe('exitSession', () => {
registerCleanup(() => {
throw new Error('boom');
});
await expect(exitSession(1)).rejects.toThrow(ExitCalled);
await expect(exitSession(config, 1)).rejects.toThrow(ExitCalled);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
+20 -6
View File
@@ -17,11 +17,13 @@
* children leaked, and usage was never persisted.
*
* Every OpenTUI exit path (Ctrl+C/Ctrl+D double press, /quit, render-error
* bailout) must go through `exitSession()`, which drains the shared cleanup
* chain first and only then exits — with signal-style exit codes (130/143)
* for interrupt-like exits instead of a bare 0.
* bailout) must go through `exitSession()`, which signals the client to stop
* spawning background work, drains the shared cleanup chain first and only then
* exits — with signal-style exit codes (130/143) for interrupt-like exits
* instead of a bare 0.
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { runExitCleanup } from '../../utils/cleanup.js';
/** Exit code for interrupt-style exits (Ctrl+C / Ctrl+D double press). */
@@ -37,18 +39,30 @@ export function isExitInProgress(): boolean {
}
/**
* Drain the registered exit-cleanup chain, then `process.exit(code)`.
* Tell the client to stop spawning background work, drain the registered
* exit-cleanup chain, then `process.exit(code)`.
*
* `requestShutdown()` first: it sets the client's shutdown flag and cancels the
* pending memory prefetch, so extract / dream / skill-review work cannot be
* spawned *during* the drain — which is what makes the process able to exit.
* ink signals it on its quit path only; every exit here means "this process is
* going down", so the signal belongs to the shared drain rather than to one
* handler.
*
* Idempotent: a second call while a drain is in flight hangs (returns a
* promise that never resolves) instead of racing the first drain — the
* process is going down either way.
* process is going down either way, and the hanging branch never re-signals.
*/
export async function exitSession(code: number): Promise<never> {
export async function exitSession(
config: Config,
code: number,
): Promise<never> {
if (exitInProgress) {
// The first drain owns the exit; never run the chain twice.
return new Promise<never>(() => {});
}
exitInProgress = true;
config.getLlmClient()?.requestShutdown();
try {
await runExitCleanup();
} catch {
+461 -71
View File
@@ -7,7 +7,10 @@
/**
* Live-turn regression tests (R2): the prompt reaches the client as a full
* PartListUnion (multimodal included) and the per-turn modelOverride travels
* through SendMessageOptions.
* through SendMessageOptions. Plus the two hops Batch 8 added: text drained at
* a tool boundary is resolved like an idle submission (`@path` expansion under
* a read deadline, with a queue restore when the turn dies mid-read), and an
* image reaching the model goes through the prompt-side vision bridge.
*/
import { beforeEach, describe, it, expect, vi } from 'vitest';
@@ -15,6 +18,7 @@ import { ApprovalMode, SendMessageType } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallConfirmationDetails,
VisionBridgeModelSelection,
} from '@qwen-code/qwen-code-core';
import {
livePromptEvents,
@@ -27,13 +31,21 @@ import type { OpenTuiStreamEvent } from './event-adapter.js';
import type { HandleAtCommandResult } from '../hooks/atCommandProcessor.js';
import { ToolCallStatus, type IndividualToolCallDisplay } from '../types.js';
// The steering test drives one full tool round-trip; replace the scheduler
// with a stub that completes the pending calls immediately.
// `runVisionBridge` is the one bridge collaborator that would reach a real
// provider, so it is recorded here; every other core export (including the
// bridge's gates and formatters) stays real.
const visionMocks = vi.hoisted(() => ({
run: vi.fn(),
}));
// The round-trip tests also replace the tool scheduler with a stub that
// completes the pending calls immediately.
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
runVisionBridge: (params: unknown) => visionMocks.run(params),
CoreToolScheduler: class FakeScheduler {
private readonly opts: {
onAllToolCallsComplete: (calls: unknown[]) => unknown;
@@ -151,16 +163,28 @@ const atMocks = vi.hoisted(() => ({
processedQuery: null,
shouldProceed: true,
} as HandleAtCommandResult,
/**
* Set to hang the expander forever — a read that ignores its signal. The hook
* runs first, so a test can abort the turn from inside the read.
*/
hang: null as (() => void) | null,
}));
vi.mock('../hooks/atCommandProcessor.js', () => ({
handleAtCommand: async ({ query }: { query: string }) => {
atMocks.calls.push(query);
if (atMocks.hang) {
atMocks.hang();
return new Promise<HandleAtCommandResult>(() => {});
}
return atMocks.result;
},
}));
function createFakeConfig(sendMessageStream: (...args: unknown[]) => unknown) {
function createFakeConfig(
sendMessageStream: (...args: unknown[]) => unknown,
bridgeModel?: VisionBridgeModelSelection,
) {
return {
initialize: vi.fn(async () => {}),
getGeminiClient: () => ({ sendMessageStream }),
@@ -168,6 +192,9 @@ function createFakeConfig(sendMessageStream: (...args: unknown[]) => unknown) {
getModel: () => 'test-model',
getMaxSessionTurns: () => 10,
getContentGeneratorConfig: () => ({ authType: 'qwen-oauth' }),
// Pinning a bridge model is what turns `shouldRunVisionBridge` on; every
// other test leaves it undefined and the prompt rides through untouched.
getDefaultVisionBridgeModel: () => bridgeModel,
getDebugLogger: () => ({
debug: () => {},
warn: () => {},
@@ -176,6 +203,26 @@ function createFakeConfig(sendMessageStream: (...args: unknown[]) => unknown) {
} as unknown as Config;
}
/** One tool batch on call 1, plain finish on the continuation call. */
function oneToolBatchStream(request: {
callId: string;
name: string;
args?: unknown;
}) {
let calls = 0;
return vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield { type: 'tool_call_request', value: request };
return;
}
yield { type: 'finished', value: {} };
});
}
async function drain(gen: AsyncGenerator<unknown>): Promise<unknown[]> {
const events = [];
for await (const ev of gen) events.push(ev);
@@ -196,10 +243,17 @@ describe('livePromptEvents', () => {
...overrides,
});
/** An attached image, as the composer hands it to the stream. */
const imagePart = {
inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' },
};
beforeEach(() => {
resetPromptCountForTesting();
atMocks.calls.length = 0;
atMocks.result = { processedQuery: null, shouldProceed: true };
atMocks.hang = null;
visionMocks.run.mockReset();
});
it('forwards string prompts as an explicit UserQuery send', async () => {
@@ -235,20 +289,10 @@ describe('livePromptEvents', () => {
});
it('keeps one promptId across the tool-continuation loop of a turn', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 't1', name: 'test_tool', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
@@ -316,20 +360,10 @@ describe('livePromptEvents', () => {
});
it('rides submittedPrompt on the first send and drops it on continuation', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 't1', name: 'test_tool', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
@@ -449,20 +483,10 @@ describe('livePromptEvents', () => {
});
it('appends drained steering texts after tool responses at the boundary', async () => {
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield {
type: 'tool_call_request',
value: { callId: 't1', name: 'test_tool', args: {} },
};
return;
}
yield { type: 'finished', value: {} };
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
@@ -488,6 +512,278 @@ describe('livePromptEvents', () => {
it('skips steering when the turn is aborted', async () => {
const drainSteering = vi.fn(() => ['never']);
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
const controller = new AbortController();
controller.abort();
await drain(
livePromptEvents(config, 'start', controller.signal, { drainSteering }),
);
expect(drainSteering).not.toHaveBeenCalled();
});
// --- The steering hop resolves the way an idle submission does (U-21) ----
const toolResponse = {
functionResponse: { name: 'test_tool', id: 't1', response: { ok: true } },
};
it('expands an @-mention in drained steering text before the boundary send', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
atMocks.result = {
processedQuery: [
{ text: 'look @src/a.ts' },
{ text: '--- Content from src/a.ts ---\nFILE BODY' },
],
shouldProceed: true,
toolDisplays: [readDisplay()],
};
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['look @src/a.ts'],
}),
)) as OpenTuiStreamEvent[];
expect(atMocks.calls).toEqual(['look @src/a.ts']);
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([
toolResponse,
{ text: 'look @src/a.ts' },
{ text: '--- Content from src/a.ts ---\nFILE BODY' },
]);
// The read the model received shows up as the settled card ink renders.
expect(events).toEqual(
expect.arrayContaining([
{
type: 'tool-start',
id: 'client-read-1',
tool: 'Read File(s)',
title: 'src/a.ts',
},
{ type: 'tool-result', id: 'client-read-1', display: 'FILE BODY' },
{ type: 'tool-end', id: 'client-read-1', success: true, summary: 'ok' },
]),
);
});
it('joins drained steering texts with a blank line between messages', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['first', 'second'],
}),
);
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([
toolResponse,
{ text: 'first' },
{ text: '\n\n' },
{ text: 'second' },
]);
expect(atMocks.calls).toEqual([]);
});
it('restores the whole drained batch when the turn dies inside a steering read', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
const controller = new AbortController();
const restoreSteering = vi.fn();
atMocks.hang = () => controller.abort();
await drain(
livePromptEvents(config, 'start', controller.signal, {
drainSteering: () => ['read @a.ts', 'then @b.ts'],
restoreSteering,
}),
);
// All-or-nothing: the resolved hop dies with the turn, so every text comes
// back instead of a half-built message reaching the model.
expect(restoreSteering).toHaveBeenCalledWith(['read @a.ts', 'then @b.ts']);
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([toolResponse]);
});
it('gives up on a hung mid-turn read instead of parking the boundary', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream);
const restoreSteering = vi.fn();
// Empty hook: nothing aborts, so only the read deadline frees the boundary.
atMocks.hang = () => {};
vi.useFakeTimers();
try {
const pending = drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['read @hanging.ts'],
restoreSteering,
}),
);
// The turn reaches the boundary through several microtask hops; pump
// until the read deadline is armed.
let armed = false;
for (let i = 0; i < 40 && !armed; i++) {
await vi.advanceTimersByTimeAsync(1);
armed = vi.getTimerCount() > 0;
}
expect(armed).toBe(true);
// ink's MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS is 10 s.
await vi.advanceTimersByTimeAsync(10_500);
const events = (await pending) as OpenTuiStreamEvent[];
expect(events.filter((e) => e.type === 'warning')).toEqual([
{
type: 'warning',
text: 'Could not attach file: Mid-turn @ command resolution timed out',
},
]);
// A timeout is the read's failure, not the turn's: the text is dropped
// like any declined expansion rather than requeued forever.
expect(restoreSteering).not.toHaveBeenCalled();
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([toolResponse]);
} finally {
vi.useRealTimers();
}
});
// --- The prompt-side vision bridge (U-25) --------------------------------
const bridgeModel: VisionBridgeModelSelection = { id: 'qwen3-vl' };
it('converts a composer image through the bridge and discloses the egress', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream, bridgeModel);
visionMocks.run.mockResolvedValue({
applied: true,
status: 'ok',
parts: [{ text: 'what is this?' }, { text: 'IMAGE TRANSCRIPT' }],
convertedCount: 1,
omittedCount: 0,
modelId: 'qwen3-vl',
egressOccurred: true,
});
const parts = [{ text: 'what is this?' }, imagePart];
const events = (await drain(
livePromptEvents(config, parts, undefined, {
submittedPrompt: 'what is this?',
}),
)) as OpenTuiStreamEvent[];
const [prompt] = sendMessageStream.mock.calls[0] as unknown[];
expect(prompt).toEqual([
{ text: 'what is this?' },
{ text: 'IMAGE TRANSCRIPT' },
]);
expect(visionMocks.run).toHaveBeenCalledWith(
expect.objectContaining({ config, parts }),
);
const notice = events.find((e) => e.type === 'info');
expect(notice?.text).toContain('Converted 1 image(s) to text via qwen3-vl');
expect(notice?.text).toContain('sent to that model');
});
it('drops the image rather than forwarding it when the bridge yields nothing', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream, bridgeModel);
visionMocks.run.mockResolvedValue({
applied: false,
status: 'failed',
convertedCount: 0,
omittedCount: 1,
modelId: 'qwen3-vl',
error: 'the vision model request failed',
});
const events = (await drain(
livePromptEvents(config, [{ text: 'look' }, imagePart]),
)) as OpenTuiStreamEvent[];
// A text-only model must never receive raw inlineData.
const [prompt] = sendMessageStream.mock.calls[0] as unknown[];
expect(prompt).toEqual([{ text: 'look' }]);
expect(events.some((e) => e.type === 'error')).toBe(true);
});
it('routes the whole turn onto an agent-capable vision model without bridging', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream, {
id: 'qwen3-vl',
agentCapable: true,
});
atMocks.result = {
processedQuery: [{ text: 'read @shot.png' }, imagePart],
shouldProceed: true,
};
const events = (await drain(
livePromptEvents(config, [{ text: 'look' }, imagePart], undefined, {
drainSteering: () => ['read @shot.png'],
}),
)) as OpenTuiStreamEvent[];
expect(visionMocks.run).not.toHaveBeenCalled();
// The images stay: the model reading them can see them.
const [firstPrompt, secondPrompt] = sendMessageStream.mock.calls.map(
(call) => (call as unknown[])[0],
);
expect(firstPrompt).toEqual([{ text: 'look' }, imagePart]);
expect(secondPrompt).toEqual([
toolResponse,
{ text: 'read @shot.png' },
imagePart,
]);
// The pick is a trailing-NUL selector, and it holds for the continuation.
const overrides = sendMessageStream.mock.calls.map(
(call) =>
(call as unknown[])[3] as { modelOverride?: string } | undefined,
);
expect(overrides.map((o) => o?.modelOverride)).toEqual([
'qwen3-vl\0',
'qwen3-vl\0',
]);
expect(events.filter((e) => e.type === 'info').map((e) => e.text)).toEqual([
'Routing this image turn to qwen3-vl; retries and tool continuations will stay on that model until the turn ends.',
]);
});
it('carries a full-turn pick made mid-turn into the rest of the turn', async () => {
// Here the image arrives only through the steered `@` mention, so the pick
// happens at the boundary — after the first send already went out on the
// primary model. A per-turn override read once would leave both the
// continuation call and the model named in its own notice on the old one.
let calls = 0;
const sendMessageStream = vi.fn(function* (): Generator<{
type: string;
@@ -501,17 +797,131 @@ describe('livePromptEvents', () => {
};
return;
}
yield { type: 'chat_compressed', value: {} };
yield { type: 'finished', value: {} };
});
const config = createFakeConfig(sendMessageStream);
const controller = new AbortController();
controller.abort();
const config = createFakeConfig(sendMessageStream, {
id: 'qwen3-vl',
agentCapable: true,
});
atMocks.result = {
processedQuery: [{ text: 'read @shot.png' }, imagePart],
shouldProceed: true,
};
const events = (await drain(
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['read @shot.png'],
}),
)) as OpenTuiStreamEvent[];
const readOptions = (call: unknown[]) =>
call[3] as { modelOverride?: string } | undefined;
expect(readOptions(sendMessageStream.mock.calls[0])).toEqual({
type: SendMessageType.UserQuery,
});
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([
toolResponse,
{ text: 'read @shot.png' },
imagePart,
]);
expect(readOptions(sendMessageStream.mock.calls[1])?.modelOverride).toBe(
'qwen3-vl\0',
);
expect(events.filter((e) => e.type === 'info').map((e) => e.text)).toEqual([
'Routing this image turn to qwen3-vl; retries and tool continuations will stay on that model until the turn ends.',
expect.stringContaining('input token limit for qwen3-vl'),
]);
});
it('leaves an inline submit_prompt override off the bridge', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream, bridgeModel);
const parts = [{ text: 'look' }, imagePart];
const events = (await drain(
livePromptEvents(config, parts, undefined, {
modelOverride: 'fast-x',
submittedPrompt: 'look',
}),
)) as OpenTuiStreamEvent[];
expect(visionMocks.run).not.toHaveBeenCalled();
const [prompt, , , options] = sendMessageStream.mock.calls[0] as unknown[];
expect(prompt).toBe(parts);
expect(options).toEqual({
type: SendMessageType.UserQuery,
modelOverride: 'fast-x',
submittedPrompt: 'look',
});
expect(events.filter((e) => e.type === 'info')).toEqual([]);
});
it('converts an image a steered @-mention pulled into the message', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
name: 'test_tool',
args: {},
});
const config = createFakeConfig(sendMessageStream, bridgeModel);
atMocks.result = {
processedQuery: [{ text: 'read @shot.png' }, imagePart],
shouldProceed: true,
};
visionMocks.run.mockResolvedValue({
applied: true,
status: 'ok',
parts: [{ text: 'read @shot.png' }, { text: 'BRIDGE TRANSCRIPT' }],
convertedCount: 1,
omittedCount: 0,
modelId: 'qwen3-vl',
egressOccurred: true,
});
await drain(
livePromptEvents(config, 'start', controller.signal, { drainSteering }),
livePromptEvents(config, 'start', undefined, {
drainSteering: () => ['read @shot.png'],
}),
);
expect(drainSteering).not.toHaveBeenCalled();
const [secondPrompt] = sendMessageStream.mock.calls[1] as unknown[];
expect(secondPrompt).toEqual([
toolResponse,
{ text: 'read @shot.png' },
{ text: 'BRIDGE TRANSCRIPT' },
]);
});
it('discloses the egress when the bridge is cancelled after sending', async () => {
const sendMessageStream = vi.fn(function* () {});
const config = createFakeConfig(sendMessageStream, bridgeModel);
const controller = new AbortController();
visionMocks.run.mockImplementation(async () => {
controller.abort();
return {
applied: false,
status: 'skipped',
convertedCount: 0,
omittedCount: 0,
modelId: 'qwen3-vl',
egressOccurred: true,
};
});
const events = (await drain(
livePromptEvents(
config,
[{ text: 'look' }, imagePart],
controller.signal,
),
)) as OpenTuiStreamEvent[];
// Esc stops the send, but the images already left: the notice survives.
expect(sendMessageStream).not.toHaveBeenCalled();
const notice = events.find((e) => e.type === 'info');
expect(notice?.text).toContain('Vision bridge cancelled.');
expect(notice?.text).toContain('were sent to qwen3-vl');
});
it('forwards awaiting_approval calls to onWaitingCall exactly once per callId', async () => {
@@ -646,26 +1056,6 @@ describe('livePromptEvents', () => {
});
describe('tool execution live output (outputUpdateHandler)', () => {
/** One tool batch on call 1, plain finish on the continuation call. */
function oneToolBatchStream(request: {
callId: string;
name: string;
args?: unknown;
}) {
let calls = 0;
return vi.fn(function* (): Generator<{
type: string;
value?: unknown;
}> {
calls += 1;
if (calls === 1) {
yield { type: 'tool_call_request', value: request };
return;
}
yield { type: 'finished', value: {} };
});
}
it('streams tool-output events while the tool executes', async () => {
const sendMessageStream = oneToolBatchStream({
callId: 't1',
+243 -16
View File
@@ -29,13 +29,23 @@ import type {
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
clampInlineMediaPart,
compactToolResultDisplayForHistory,
CoreToolScheduler,
didWriteProjectContextFile,
formatFullTurnVisionNotice,
formatVisionBridgeNotice,
getErrorMessage,
getFullTurnVisionModelSelector,
hasImageParts,
isShellProgressData,
normalizeParts,
parseAndFormatApiError,
refreshMemoryInstruction,
runVisionBridge,
SendMessageType,
shouldRunVisionBridge,
splitImageParts,
ToolNames,
} from '@qwen-code/qwen-code-core';
import type { Part, PartListUnion } from '@google/genai';
@@ -65,12 +75,21 @@ export interface LivePromptOptions {
modelOverride?: string;
/**
* "Enter to steer" parity: called at each tool boundary (after tools ran,
* before their results go back to the model). Returned texts are appended
* after the functionResponse parts as genuine user content, exactly like
* the original's drainSteerAtBoundary sampling-boundary drain. Skipped
* when the turn is aborted (messages then stay queued).
* before their results go back to the model). Returned texts are resolved
* the way ink resolves a steered message — `@path` expansion, then the
* prompt-side vision bridge — and appended after the functionResponse parts
* as genuine user content (the original's drainSteerAtBoundary sampling-
* boundary drain). Skipped when the turn is aborted (messages then stay
* queued).
*/
drainSteering?: () => string[];
/**
* Puts drained steering texts back at the front of the queue (ink's
* `midTurnRestoreRef`). Resolution is all-or-nothing — an abort anywhere in
* it sends nothing to the model, so every text that left `drainSteering`
* comes back here rather than vanishing with the turn.
*/
restoreSteering?: (texts: readonly string[]) => void;
/**
* Scheduler-level confirmation requests the permission flow did not
* auto-approve (ask_user_question in every mode; edit/exec in DEFAULT).
@@ -256,8 +275,9 @@ function atMentionCardEvents(
* mentions are expanded where the prompt enters the stream, never at the
* composer, so the transcript keeps what the user typed and queued text that
* survives to become the next turn is expanded by the time it reaches the
* model. Text drained mid-turn as steering rides raw — ink expands that hop
* too (`resolveSteeredMessages`); replicating it is tracked in #8662.
* model. Text drained mid-turn goes through the same expansion —
* {@link resolveSteeredPromptParts} adds the read deadline ink puts on that
* hop ({@link expandAtMentions}'s caller waits indefinitely).
*
* `events` carries the read cards even when the expansion declines — the one
* decline is a failed read, and ink reports it instead of sending the
@@ -286,6 +306,189 @@ async function expandAtMentions(
return { parts: result.processedQuery, events, declined: false };
}
/**
* The per-turn model override as the vision bridge sees it — ink's two coupled
* refs. A trailing NUL on `current` marks the bridge's own full-turn pick;
* `inline` means the override came from `submit_prompt` and wins over the
* bridge for the whole turn.
*/
interface TurnModelOverride {
current: string | undefined;
inline: boolean;
}
/**
* ink parity (`applyVisionBridgeIfNeeded`): with a vision bridge configured and
* a primary model that cannot read images, an image must be converted before it
* reaches the model — never forwarded as raw `inlineData`. The shape follows the
* other non-ink port of this hop (acp-integration
* `Session.#applyBridgeConversionsIfNeeded`) rather than ink's React hook.
*
* `null` parts mean "do not send this": the turn stops (fresh hop) or the
* message is dropped (steering hop), exactly as ink declines.
*/
async function applyPromptVisionBridge(
config: Config,
parts: PartListUnion,
signal: AbortSignal,
turnModel: TurnModelOverride,
): Promise<{ parts: PartListUnion | null; events: OpenTuiStreamEvent[] }> {
const events: OpenTuiStreamEvent[] = [];
// Every skip path hands back the caller's own parts, untouched and
// unnormalized — ink does, so a plain text prompt keeps its shape.
if (!hasImageParts(parts)) return { parts, events };
if (turnModel.current?.endsWith('\0') || turnModel.inline) {
return { parts, events };
}
if (!shouldRunVisionBridge(config)) return { parts, events };
if (signal.aborted) return { parts: null, events };
const fullTurnModel = config.getDefaultVisionBridgeModel();
if (fullTurnModel?.agentCapable) {
// The whole turn moves to the image-capable model, so the images stay.
const fullTurnParts = normalizeParts(parts).map((part) =>
clampInlineMediaPart(part),
);
if (!hasImageParts(fullTurnParts)) return { parts: fullTurnParts, events };
turnModel.current = getFullTurnVisionModelSelector(fullTurnModel);
// ink shows this as a `vision_notice` row; OpenTUI has no separate notice
// kind, so it rides the info row the bridge's own text already fills.
events.push({
type: 'info',
text: formatFullTurnVisionNotice(fullTurnModel),
});
return { parts: fullTurnParts, events };
}
const bridgeResult = await runVisionBridge({ config, parts, signal });
// One notice either way: the egress disclosure on success, the reason when
// the attempt failed or cancelled after images had already been sent.
if (bridgeResult.status !== 'skipped' || bridgeResult.egressOccurred) {
events.push({
type: bridgeResult.status === 'failed' ? 'error' : 'info',
text: formatVisionBridgeNotice(bridgeResult),
});
}
if (signal.aborted) return { parts: null, events };
if (bridgeResult.applied && bridgeResult.parts != null) {
return { parts: normalizeParts(bridgeResult.parts), events };
}
// No usable replacement: drop the images and proceed on the remaining text.
const textOnly = splitImageParts(parts).nonImageParts;
return { parts: textOnly.length > 0 ? textOnly : null, events };
}
/** Rejects as soon as `signal` fires, so a read that ignores it cannot park the boundary. */
function abortRejection(signal: AbortSignal): Promise<never> {
return new Promise<never>((_, reject) => {
const rejectWithAbort = () =>
reject(
signal.reason ?? new Error('Mid-turn @ command resolution aborted'),
);
if (signal.aborted) rejectWithAbort();
else signal.addEventListener('abort', rejectWithAbort, { once: true });
});
}
// ink's MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS: a hung read must not hold the
// sampling boundary open indefinitely.
const MID_TURN_STEER_READ_TIMEOUT_MS = 10_000;
interface SteeredPromptResolution {
/** User content for the model, segments joined by a blank line as ink joins them. */
parts: Part[];
/** Read cards and bridge notices — discarded when the hop is restored. */
events: OpenTuiStreamEvent[];
/**
* Texts to put back at the front of the queue. All of them or none: ink
* discards the resolved parts when an abort lands, so nothing can go out
* twice.
*/
restore: string[];
}
/**
* ink parity (`resolveSteeredMessages`): what the composer queued mid-turn is
* resolved the same way an idle submission is — `@path` expansion, then the
* prompt-side vision bridge — before it rides to the model as steering.
*
* Not ported, on purpose: ink's slash interception at this boundary (its goal
* command has no OpenTUI counterpart) and its two-phase recording, which
* defers the read cards until the messages are committed. Here the cards go out
* as they are produced, and an abort drops the whole hop instead.
*/
async function resolveSteeredPromptParts(
config: Config,
texts: readonly string[],
signal: AbortSignal,
turnModel: TurnModelOverride,
): Promise<SteeredPromptResolution> {
const restore = (): SteeredPromptResolution => ({
parts: [],
events: [],
restore: [...texts],
});
const events: OpenTuiStreamEvent[] = [];
const segments: Part[][] = [];
for (const message of texts) {
if (signal.aborted) return restore();
let resolved: PartListUnion = [{ text: message }];
if (isAtCommand(message)) {
const deadline = new AbortController();
const readSignal = AbortSignal.any([signal, deadline.signal]);
const timer = setTimeout(() => {
deadline.abort(new Error('Mid-turn @ command resolution timed out'));
}, MID_TURN_STEER_READ_TIMEOUT_MS);
let expanded: Awaited<ReturnType<typeof expandAtMentions>>;
try {
expanded = await Promise.race([
expandAtMentions(config, message, readSignal),
abortRejection(readSignal),
]);
} catch (error) {
const reason = getErrorMessage(error);
config.getDebugLogger().debug(`Mid-turn @ command failed: ${reason}`);
if (signal.aborted) return restore();
events.push({
type: 'warning',
text: `Could not attach file: ${reason}`,
});
continue;
} finally {
clearTimeout(timer);
}
events.push(...expanded.events);
if (expanded.declined) {
if (signal.aborted) return restore();
continue;
}
resolved = expanded.parts;
}
const bridged = await applyPromptVisionBridge(
config,
resolved,
signal,
turnModel,
);
events.push(...bridged.events);
if (bridged.parts === null) {
if (signal.aborted) return restore();
continue;
}
const messageParts = normalizeParts(bridged.parts);
if (messageParts.length > 0) segments.push(messageParts);
}
const parts: Part[] = [];
for (const segment of segments) {
if (parts.length > 0) parts.push({ text: '\n\n' });
parts.push(...segment);
}
return { parts, events, restore: [] };
}
/**
* Sends one user prompt through the real client and yields neutral events.
* The caller (backend) drains this into the streaming model.
@@ -308,6 +511,13 @@ export async function* livePromptEvents(
}
const client = config.getGeminiClient();
const promptId = options?.promptId ?? nextLivePromptId(config);
const abort = signal ?? new AbortController().signal;
// Read per boundary rather than once: the vision bridge can pick a full-turn
// model mid-turn, and the rest of the turn then stays on it (ink parity).
const turnModel: TurnModelOverride = {
current: options?.modelOverride,
inline: options?.modelOverride !== undefined,
};
const map = createEventMapper({
// ink handleErrorEvent parity: auth-aware formatting. The Ctrl+Y retry
// hint travels on the error event's `hint` field (ErrorMessage renders
@@ -322,10 +532,9 @@ export async function* livePromptEvents(
return error instanceof Error ? error.message : String(error);
}
},
getModelName: () => options?.modelOverride ?? config.getModel(),
getModelName: () => turnModel.current ?? config.getModel(),
getMaxSessionTurns: () => config.getMaxSessionTurns(),
});
const abort = signal ?? new AbortController().signal;
const dbg = process.env['QWEN_OPENTUI_DEBUG'];
// The ink app drives tool EXECUTION via useReactToolScheduler: the client
@@ -350,24 +559,31 @@ export async function* livePromptEvents(
if (expanded.declined) return;
nextPrompt = expanded.parts;
}
// ink runs the bridge on every query it hands the model
// (`prepareQueryForLlm`), so an image attached at the composer converts here.
const bridged = await applyPromptVisionBridge(
config,
nextPrompt,
abort,
turnModel,
);
for (const ev of bridged.events) yield ev;
if (bridged.parts === null) return;
nextPrompt = bridged.parts;
let first = true;
const waitingSeen = new Set<string>();
for (;;) {
const sendOptions = first
? {
type: SendMessageType.UserQuery,
...(options?.modelOverride
? { modelOverride: options.modelOverride }
: {}),
...(turnModel.current ? { modelOverride: turnModel.current } : {}),
...(options?.submittedPrompt
? { submittedPrompt: options.submittedPrompt }
: {}),
}
: {
type: SendMessageType.ToolResult,
...(options?.modelOverride
? { modelOverride: options.modelOverride }
: {}),
...(turnModel.current ? { modelOverride: turnModel.current } : {}),
};
first = false;
const pending: Array<{ callId: string; name: string; args?: unknown }> = [];
@@ -560,8 +776,19 @@ export async function* livePromptEvents(
// Sampling boundary: drained steering rides after the tool responses as
// genuine user content (original useGeminiStream mid-turn drain).
if (!abort.aborted) {
for (const text of options?.drainSteering?.() ?? []) {
if (text) responseParts.push({ text });
const texts = (options?.drainSteering?.() ?? []).filter((text) => text);
if (texts.length > 0) {
const steered = await resolveSteeredPromptParts(
config,
texts,
abort,
turnModel,
);
if (steered.restore.length > 0) {
options?.restoreSteering?.(steered.restore);
}
for (const ev of steered.events) yield ev;
responseParts.push(...steered.parts);
}
}
// Context-file write check (ink useGeminiStream parity): a slash command
+37 -2
View File
@@ -8,8 +8,9 @@
/**
* Pure-logic coverage for the live-turn driver: composer attachment folding
* (unsupported/unreadable images must surface as notices, never vanish), the
* replay-batch fold (the transcript reset path for session switches), and the
* mid-turn queue hand-off to the next turn.
* replay-batch fold (the transcript reset path for session switches), the
* mid-turn queue hand-off to the next turn, and the queue taking back a
* steering batch the stream layer could not resolve.
*/
import { mkdtempSync, writeFileSync } from 'node:fs';
@@ -195,4 +196,38 @@ describe('useOpenTuiLiveTurn submit paths', () => {
expect(live.turns[2]?.prompt).toBe('then do the other thing');
expect(result.current.queueLength).toBe(0);
});
it('restores a dropped steering batch at the front of the queue', () => {
// The stream layer owns `@path` expansion and can hand a drained batch
// back when the turn dies resolving it. Front-prepend (ink's
// restoreMessages) keeps the steering order: restored first, then whatever
// the composer queued since.
const { result } = renderHook(() =>
useOpenTuiLiveTurn({ config: {} as Config }),
);
act(() => {
result.current.submit('first prompt');
});
act(() => {
result.current.submit('queued after');
});
expect(result.current.queueLength).toBe(1);
const { restoreSteering } = live.turns[0]?.options as unknown as {
restoreSteering: (texts: readonly string[]) => void;
};
act(() => {
restoreSteering([' steer me ', '', 'then @b.ts']);
});
expect(result.current.queueLength).toBe(3);
let popped: string | null = null;
act(() => {
popped = result.current.popQueue();
});
expect(result.current.queueLength).toBe(0);
expect(popped).toBe('steer me\nthen @b.ts\nqueued after');
});
});
+14 -7
View File
@@ -17,12 +17,11 @@
* never silently dropped.
*
* `@path` mentions are expanded where a prompt enters the stream
* ({@link livePromptEvents}), never here: an idle submit and queued text that
* becomes the next turn both reach the model with file content while the
* transcript keeps what was typed. Text drained as in-flight steering still
* rides raw — ink expands that hop too (`resolveSteeredMessages`, with a read
* timeout and a queue restore on cancel) — and the model can resolve the
* literal mention with a read tool.
* ({@link livePromptEvents}), never here: an idle submit, queued text that
* becomes the next turn, and text drained as in-flight steering all reach the
* model with file content, while the transcript keeps what was typed. A
* steering resolution the user interrupts mid-read hands its texts back to this
* queue instead of dropping them.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -188,6 +187,13 @@ export function useOpenTuiLiveTurn(
return drained;
}, []);
const restoreQueue = useCallback((texts: readonly string[]) => {
const restored = texts.map((text) => text.trim()).filter(Boolean);
if (restored.length === 0) return;
queueRef.current = [...restored, ...queueRef.current];
setQueueLength(queueRef.current.length);
}, []);
const runTurn = useCallback(
async (
prompt: PartListUnion,
@@ -205,6 +211,7 @@ export function useOpenTuiLiveTurn(
submittedPrompt: turnOptions?.submittedPrompt,
refreshContextFilesOnWrite: turnOptions?.refreshContextFilesOnWrite,
drainSteering: drainQueue,
restoreSteering: restoreQueue,
onWaitingCall: (call) => {
if (seq !== turnSeqRef.current) return;
setWaitingCalls((prev) =>
@@ -257,7 +264,7 @@ export function useOpenTuiLiveTurn(
}
}
},
[config, apply, drainQueue, setBusy],
[config, apply, drainQueue, restoreQueue, setBusy],
);
const submit = useCallback(
@@ -63,6 +63,8 @@ const mocks = vi.hoisted(() => {
actionConfirmProps: null as Record<string, unknown> | null,
keyboardHandlers: [] as Array<(key: unknown) => void>,
exitInProgress: false,
/** Runs while a dispatched command is still awaiting its outcome. */
onHandle: null as null | ((text: string) => void),
};
async function buildJsxRuntime() {
const React = await import('react');
@@ -136,6 +138,7 @@ vi.mock('./commands-dispatch.js', () => ({
dispose() {}
async handle(text: string) {
mocks.state.handledTexts.push(text);
mocks.state.onHandle?.(text);
const queued = mocks.state.handleResults;
return queued.length > 0 ? queued.shift() : mocks.state.handleResult;
}
@@ -225,6 +228,7 @@ describe('OpenTuiApp shell wiring', () => {
mocks.state.actionConfirmProps = null;
mocks.state.keyboardHandlers.length = 0;
mocks.state.exitInProgress = false;
mocks.state.onHandle = null;
});
it('renders the composer inside the error boundary by default', async () => {
@@ -569,6 +573,36 @@ describe('OpenTuiApp shell wiring', () => {
);
});
it('normalizes bare quit tokens ahead of the mid-turn gate and the dispatch', async () => {
// ink normalizes the whole quit family where its handleFinalSubmit puts the
// check — before the queue — so an `exit` typed mid-response stops the stream
// instead of queueing behind it or reaching the model as text. The text the
// gate is asked about is the ordering witness: `/quit`, never `exit`.
const gateSeen: string[] = [];
mocks.state.deferGate = (text) => {
gateSeen.push(text);
const command = text.trim();
return command.startsWith('/') && command !== '/quit';
};
const onQuit = vi.fn();
const onSubmitPrompt = vi.fn();
renderApp({ streaming: true, onQuit, onSubmitPrompt });
await settle();
mocks.state.handleResult = {
kind: 'quit',
messages: [],
} satisfies OpenTuiDispatchOutcome;
const tokens = ['exit', 'quit', ':q', ':q!', ':wq', ':wq!'];
for (const token of tokens) await submit(token);
expect(gateSeen).toEqual(tokens.map(() => '/quit'));
expect(mocks.state.handledTexts).toEqual(tokens.map(() => '/quit'));
expect(onQuit).toHaveBeenCalledTimes(tokens.length);
expect(onSubmitPrompt).not.toHaveBeenCalled();
expect(screen.queryByText(/Queued/)).toBeNull();
});
it('drains a held command whose defer verdict lands after the idle edge', async () => {
// The gate awaits the command registry, so its verdict can land after the
// turn it was asked about has already ended (R1-1).
@@ -821,6 +855,36 @@ describe('OpenTuiApp shell wiring', () => {
expect(mocks.state.handledTexts).toEqual([]);
});
it('stops between dispatches when the exit starts while one is in flight', async () => {
// The crossing the edge check cannot see: the drain is already past it,
// and the exit begins while '/first' awaits its outcome. Only the in-loop
// latch check keeps '/second' back (R4-1).
mocks.state.deferDuringStreaming = true;
const props = {
config: CONFIG,
settings: SETTINGS,
logger: null,
commands: [] as readonly SlashCommand[],
getSessionStats,
streaming: true,
};
const view = render(<OpenTuiApp {...props} />);
await settle();
await submit('/first');
await submit('/second');
expect(mocks.state.handledTexts).toEqual([]);
mocks.state.onHandle = () => {
mocks.state.exitInProgress = true;
};
await act(async () => {
view.rerender(<OpenTuiApp {...props} streaming={false} />);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(mocks.state.handledTexts).toEqual(['/first']);
});
it('catches a subtree render error inside the error boundary', async () => {
const boom = vi.spyOn(console, 'error').mockImplementation(() => {});
const ThrowingView = () => {
@@ -53,7 +53,10 @@ import type { ShellConfirmationResolution } from './commands-context.js';
import type { WaitingCallInfo } from './live-session.js';
import type { OpenTuiSubmitOptions } from './live-turn.js';
import { OpenTuiAppHost } from './opentui-host.js';
import { OpenTuiSlashGateway } from './slash-gateway.js';
import {
normalizeQuitSubmission,
OpenTuiSlashGateway,
} from './slash-gateway.js';
import {
OpenTuiSlashDispatcher,
type OpenTuiDispatchOutcome,
@@ -385,11 +388,15 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
const onSubmit = useCallback(
async (text: string, imagePaths?: string[]) => {
setNoticeText(null);
// Ahead of the gate and the dispatch, exactly where ink's
// handleFinalSubmit puts it: a quit has to be able to stop the stream, so
// it must not be deferred behind the turn or reach the model as text.
const submission = normalizeQuitSubmission(text);
// ink parity (AppContainer.handleFinalSubmit): while a turn responds,
// only a command that opted into canRunDuringStreaming runs now — the
// rest wait for idle instead of racing the stream.
if (streaming && (await gateway.mustDeferDuringStreaming(text))) {
const command = text.trim();
if (streaming && (await gateway.mustDeferDuringStreaming(submission))) {
const command = submission.trim();
deferredCommandsRef.current.push(command);
setDeferredRevision((revision) => revision + 1);
notify(
@@ -397,7 +404,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
);
return;
}
const settlement = await gateway.dispatch(text);
const settlement = await gateway.dispatch(submission);
if (settlement.kind === 'rejected') {
notify(settlement.reason);
return;
@@ -12,7 +12,10 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { OpenTuiSlashGateway } from './slash-gateway.js';
import {
normalizeQuitSubmission,
OpenTuiSlashGateway,
} from './slash-gateway.js';
import type {
OpenTuiDispatchOutcome,
OpenTuiSlashDispatcher,
@@ -153,3 +156,26 @@ describe('OpenTuiSlashGateway', () => {
expect(second.handle).toHaveBeenCalledTimes(1);
});
});
describe('normalizeQuitSubmission', () => {
it.each([
'exit',
'quit',
':q',
':q!',
':wq',
':wq!',
'/exit',
'/quit',
' exit ',
])('routes the bare quit token %s to /quit', (text) => {
expect(normalizeQuitSubmission(text)).toBe('/quit');
});
it.each(['exit the maze', 'quit smoking', '/compress', ':qn', 'EXIT'])(
'leaves %s untouched',
(text) => {
expect(normalizeQuitSubmission(text)).toBe(text);
},
);
});
+26 -1
View File
@@ -16,7 +16,9 @@
* submission instead of silently misrouting `/help` to the model;
* - rejects a submission while a command is already running (the ink
* processor gates on isProcessing; the OpenTUI prompt does not disable);
* - routes Esc to `dispatcher.cancel()` while a command runs.
* - routes Esc to `dispatcher.cancel()` while a command runs;
* - normalizes ink's bare quit tokens to `/quit` for the shell, ahead of the
* gate ({@link normalizeQuitSubmission}).
*/
import type {
@@ -24,6 +26,29 @@ import type {
OpenTuiSlashDispatcher,
} from './commands-dispatch.js';
// ink's quit family (AppContainer.handleFinalSubmit): `/exit` is quit's own
// altName, so the whole set collapses onto `/quit`.
const QUIT_TOKENS = new Set([
'/quit',
'/exit',
'exit',
'quit',
':q',
':q!',
':wq',
':wq!',
]);
/**
* Rewrites a bare quit token to `/quit` so it reaches the dispatcher as the
* command it means. ink checks this ahead of its message queue — a quit has to
* be able to stop a live stream instead of queueing behind it, and anything the
* gate leaves alone would otherwise be submitted to the model as text.
*/
export function normalizeQuitSubmission(text: string): string {
return QUIT_TOKENS.has(text.trim()) ? '/quit' : text;
}
export type SlashSettlement =
/** The dispatcher processed the input (false = not a slash command). */
| { kind: 'dispatched'; outcome: OpenTuiDispatchOutcome | false }
@@ -207,7 +207,7 @@ function OpenTuiEntryApp({
}
const guardKey = key.name === 'd' ? 'ctrl-d' : 'ctrl-c';
if (exitGuard.press(guardKey) === 'exit') {
void exitSession(EXIT_CODE_INTERRUPT);
void exitSession(config, EXIT_CODE_INTERRUPT);
} else {
setExitHint(exitGuardHint(guardKey));
}
@@ -228,16 +228,19 @@ function OpenTuiEntryApp({
[live.items, width, height, exitHint],
);
const handleRenderError = useCallback((error: Error) => {
debugLogger.error(
`[FATAL_RENDER_ERROR] ${error.message}\n${error.stack ?? ''}`,
);
// ink parity: the fallback unmounted the composer and Ctrl+C handling;
// schedule a graceful exit so the session cannot hang.
setTimeout(() => {
void exitSession(1);
}, 5000);
}, []);
const handleRenderError = useCallback(
(error: Error) => {
debugLogger.error(
`[FATAL_RENDER_ERROR] ${error.message}\n${error.stack ?? ''}`,
);
// ink parity: the fallback unmounted the composer and Ctrl+C handling;
// schedule a graceful exit so the session cannot hang.
setTimeout(() => {
void exitSession(config, 1);
}, 5000);
},
[config],
);
const handleStartNewSession = useCallback(
(sessionId: string) => startNewSession(sessionId),
@@ -254,8 +257,8 @@ function OpenTuiEntryApp({
);
const handleQuit = useCallback(() => {
void exitSession(0);
}, []);
void exitSession(config, 0);
}, [config]);
return (
<OpenTuiApp
+8 -1
View File
@@ -76,6 +76,7 @@ artifacts and reports:
"fixed": { "argv": ["argv..."], "pty": "fixture" }
},
"thresholds": { "maxFullScreenClears": 0 },
"expectBaseFailure": false,
"proves": "what a passing run proves",
"doesNotProve": "what it cannot prove"
}
@@ -85,6 +86,11 @@ artifacts and reports:
object with `argv` and optional `pty` (`"fixture"` or `"wrapped"`). Any other
per-side key is rejected.
`expectBaseFailure` (optional boolean, default `false`) declares that the base
side is a defect fixture rather than a clean reference. Such a scenario passes
only when the base side fails: a `both-pass` there means the fixture emitted no
defect, so the comparison would report success while proving nothing.
Thresholds (all optional, evaluated per side): `maxFullScreenClears`,
`maxPartialScreenErases`, `maxLineErases`, `maxDuplicateEvents`,
`maxDec2026Unbalanced` (integers >= 0), and `requireSync`,
@@ -149,7 +155,8 @@ run-summary.json
Outcomes: `base-fails-fixed-passes` (evidence that the fix removes the
defect), `both-pass` (no base-side defect exhibited), `fixed-fails`
(threshold violation), `capture-error` (spawn failure, refusal, or timeout).
The run passes only on the first two.
The run passes only on the first two — and for a scenario with
`expectBaseFailure`, only on `base-fails-fixed-passes`.
## Fixtures and tests
@@ -1,8 +1,9 @@
{
"id": "opentui-noflicker-offline",
"description": "Offline OpenTUI no-flicker gate: the real OpenTUI backend streams its scripted demo (no model credentials) with ZERO full-screen clears and balanced DEC 2026 under PTY capture. Runs on fork PRs where secrets.QWEN_API_KEY is unavailable; maintainers with credentials get the live-model opentui-noflicker gate instead.",
"proves": "fixed (real OpenTUI renderer, scripted stream) has 0 full-screen clears and 0 unbalanced DEC 2026; base (fixture emitter with injected defects) is the reference.",
"proves": "fixed (real OpenTUI renderer, scripted stream) has 0 full-screen clears and 0 unbalanced DEC 2026 against a base fixture that must fail; a both-pass means the fixture lost its injected defect and the run proves nothing.",
"doesNotProve": "live-model streaming behavior; visual identity; ink-vs-opentui parity.",
"expectBaseFailure": true,
"terminal": { "rows": 30, "columns": 100 },
"timeoutMs": 30000,
"input": [],
+11
View File
@@ -11,6 +11,7 @@ const TOP_LEVEL_KEYS = new Set([
'commands',
'compareParams',
'thresholds',
'expectBaseFailure',
'proves',
'doesNotProve',
]);
@@ -356,6 +357,15 @@ export function validateScenario(input, label = 'scenario') {
}
}
let expectBaseFailure = false;
if (input.expectBaseFailure !== undefined) {
if (typeof input.expectBaseFailure !== 'boolean') {
problems.push('expectBaseFailure must be a boolean');
} else {
expectBaseFailure = input.expectBaseFailure;
}
}
if (problems.length > 0) {
throw new Error(`scenario ${label}: ${problems.join('; ')}`);
}
@@ -369,6 +379,7 @@ export function validateScenario(input, label = 'scenario') {
commands,
compareParams,
thresholds,
expectBaseFailure,
proves: input.proves,
doesNotProve: input.doesNotProve,
};
+22 -2
View File
@@ -226,7 +226,13 @@ export function buildComparison(
fixedVerdict: fixed.eval.verdict,
deltas,
outcome,
harnessPass: PASSING_OUTCOMES.has(outcome),
expectBaseFailure: scenario.expectBaseFailure === true,
// A scenario whose base side is a defect fixture proves nothing when the
// base passes: both-pass there means the fixture lost its defect, not that
// the fixed side matched a clean reference.
harnessPass:
PASSING_OUTCOMES.has(outcome) &&
!(scenario.expectBaseFailure === true && outcome === 'both-pass'),
proves: scenario.proves,
doesNotProve: scenario.doesNotProve,
};
@@ -280,6 +286,16 @@ export function renderReport(comparison) {
lines.push(
`- Outcome: **${c.outcome}** (base=${c.baseVerdict}, fixed=${c.fixedVerdict})`,
);
if (c.expectBaseFailure) {
lines.push(
c.outcome === 'both-pass'
? '- Gate: **failed** — this scenario requires the base side to fail, so ' +
'a both-pass means the base fixture emitted no defect and the ' +
'comparison proves nothing.'
: '- Gate: this scenario requires the base side to fail (its base is a ' +
'defect fixture, so a clean base proves nothing).',
);
}
lines.push('');
lines.push('## Commands');
lines.push('');
@@ -562,8 +578,12 @@ async function main() {
});
for (const error of result.errors) console.error(`error: ${error}`);
for (const c of result.scenarios) {
const gate =
c.expectBaseFailure && c.outcome === 'both-pass'
? ' (requires a failing base: the base fixture emitted no defect)'
: '';
console.log(
`${c.scenarioId}: outcome=${c.outcome} base=${c.baseVerdict} fixed=${c.fixedVerdict}`,
`${c.scenarioId}: outcome=${c.outcome} base=${c.baseVerdict} fixed=${c.fixedVerdict}${gate}`,
);
}
console.log(`overall: ${result.overall}`);
+89
View File
@@ -29,6 +29,12 @@ const HANDSHAKE_PATH = fileURLToPath(
const PTY_LAUNCHER_PATH = fileURLToPath(
new URL('../fixtures/wrappers/pty-launcher.mjs', import.meta.url),
);
const OFFLINE_SCENARIO = fileURLToPath(
new URL(
'../fixtures/scenarios/opentui-noflicker-offline.scenario.json',
import.meta.url,
),
);
const fixtureCommand = (...flags) => ({
argv: [...EMITTER, ...flags],
@@ -166,6 +172,89 @@ test('runScenarios fails when fixed exceeds thresholds', async () => {
});
});
test('expectBaseFailure rejects a both-pass of the same commands', async () => {
await withWorkdir(async (work) => {
// Identical clean output on both sides: the only variable is whether the
// scenario declares its base a defect fixture that has to fail.
const cleanCommands = () => ({
base: fixtureCommand('--frames', '1', '--sync'),
fixed: fixtureCommand('--frames', '1', '--sync'),
});
const tolerated = await runScenario(
validateScenario(scenarioFixture({ commands: cleanCommands() })),
join(work, 'tolerated'),
);
assert.equal(tolerated.outcome, 'both-pass');
assert.equal(tolerated.harnessPass, true);
const required = await runScenario(
validateScenario(
scenarioFixture({
id: 'base-must-fail',
commands: cleanCommands(),
expectBaseFailure: true,
}),
),
join(work, 'required'),
);
assert.equal(required.outcome, 'both-pass');
assert.equal(required.harnessPass, false);
// A passing-looking verdict has to say why it failed the gate.
const report = await readFile(
join(work, 'required', 'base-must-fail', 'report.md'),
'utf8',
);
assert.match(report, /requires the base side to fail/);
// The flag requires a failing base, not a failing scenario.
const defective = await runScenario(
validateScenario(scenarioFixture({ expectBaseFailure: true })),
join(work, 'defective'),
);
assert.equal(defective.outcome, 'base-fails-fixed-passes');
assert.equal(defective.harnessPass, true);
});
});
test('the offline gate requires its base fixture to keep showing a defect', async () => {
await withWorkdir(async (work) => {
// bun is only needed for the real fixed side, so both cases override it
// with a clean emitter: tightening this gate must not make it red either.
const fixture = JSON.parse(await readFile(OFFLINE_SCENARIO, 'utf8'));
const clean = fixtureCommand('--frames', '1', '--sync').argv;
const defective = await runScenarios({
paths: [OFFLINE_SCENARIO],
outDir: join(work, 'defective'),
baseOverride: fixture.commands.base.argv,
fixedOverride: clean,
});
assert.equal(defective.scenarios[0].outcome, 'base-fails-fixed-passes');
assert.equal(defective.overall, 'pass');
const healed = await runScenarios({
paths: [OFFLINE_SCENARIO],
outDir: join(work, 'healed'),
baseOverride: clean,
fixedOverride: clean,
});
assert.equal(healed.scenarios[0].outcome, 'both-pass');
assert.equal(healed.overall, 'fail');
});
});
test('expectBaseFailure is an optional boolean', () => {
assert.equal(validateScenario(scenarioFixture()).expectBaseFailure, false);
assert.equal(
validateScenario(scenarioFixture({ expectBaseFailure: true }))
.expectBaseFailure,
true,
);
assert.throws(
() => validateScenario(scenarioFixture({ expectBaseFailure: 'yes' })),
/expectBaseFailure must be a boolean/,
);
});
test('runScenarios reports validation errors without running', async () => {
await withWorkdir(async (work) => {
const scenario = scenarioFixture({ id: 'divergent' });