fix(cli): align confirmed keyboard exit status with Ink (#15723)

This commit is contained in:
ChiGao authored and GitHub committed 2026-10-29 09:11:40 +00:00
1 parent a743220bda
commit 37972dcd9d
9 files changed
+62 -35

No files matched your search

@@ -74,7 +74,7 @@ Focused tests use a real bridge/adapter and owned JSONL; only the core/scheduler
The MOD projection cases check finalized parts at the actual bridge, model continuation and recording consumers before checking JSON. Both nonempty-display and empty-display variants require one logical result in order between tool use and the next assistant; neither variant changes the shared serialization contract. Preserve the original incorrect JSON-only assertion failure as test-oracle evidence, not a production fix.
Acceptance requires matching actual Ink event families/order/IDs for supported paths, no duplicates from queue/replay/retry, no stale output into a successor, one session_start/session_end on orderly termination, and no captured live product after orderly exit. Read complete flushed lines and reject malformed JSON/ANSI. The ordinary cases use `/quit` with exit 0; the active raw-CtrlC baseline records Ink 0 and current OpenTUI 130 explicitly, without accepting arbitrary nonzero exits or changing product semantics.
Acceptance requires matching actual Ink event families/order/IDs for supported paths, no duplicates from queue/replay/retry, no stale output into a successor, one session_start/session_end on orderly termination, and no captured live product after orderly exit. Read complete flushed lines and reject malformed JSON/ANSI. The ordinary `/quit` cases and confirmed active raw-CtrlC keyboard exits must use exit 0 after the shared cleanup drain across all three profiles. Preserve the historical Ink 0/OpenTUI 130 baseline as the mismatch being corrected; actual OS-signal termination keeps its signal code. No arbitrary nonzero exit or harness cleanup qualifies as graceful exit.
The preparation fixtures are independently versioned and hash-pinned. Original R3 v2 results and manifests remain immutable. Baseline collection, implementation verification and independent review are separate evidence stages. No future commit may claim the old R3 tarball contains changed source.
@@ -74,7 +74,7 @@ focused 使用真实 bridge/adapter 和独占 JSONL,仅控制 core/scheduler
MOD 投影用例先在真实 bridge、模型 continuation 和录制消费者处核对最终 parts,再检查 JSON。非空 display 与空 display 两个变体都要求逻辑结果仅出现一次,且位于 tool use 与下一条 assistant 之间;两者均不改变共享序列化合同。保留原先只检查 JSON 而预期错误的失败记录,将其归为测试断言证据,不称为生产修复。
验收要求支持路径匹配真实 Ink 的事件类别/顺序/ID,queue/replay/retry 不重复,旧流不污染后继,正常关闭 session_start/session_end 各一次,正常退出后没有捕获的产品活体。只解析完整 flush 的行,拒绝坏 JSON/ANSI。普通场景 `/quit` 期望 0;active raw CtrlC 基线明确记录 Ink 0 和当前 OpenTUI 130,不接受任意非零码,也不改变产品语义。
验收要求支持路径匹配真实 Ink 的事件类别/顺序/ID,queue/replay/retry 不重复,旧流不污染后继,正常关闭 session_start/session_end 各一次,正常退出后没有捕获的产品活体。只解析完整 flush 的行,拒绝坏 JSON/ANSI。普通 `/quit` 和确认的 active raw CtrlC 键盘退出,在三个 profile 均须完成共享清理后以 0 退出。保留历史 Ink 0/OpenTUI 130 基线,作为本次修正的差异;真实 OS signal 终止保留原信号码。任意非零码和 harness 清理均不等于优雅退出。
准备夹具独立版本化并锁 hash。原 R3 v2 结果与 manifest 不变。基线收集、实现验证、独立审查是不同证据阶段。后续 commit 不可声称旧 R3 tarball 包含变更后的源码。
@@ -63,7 +63,10 @@ Read the live composer buffer for Ctrl+D: nonempty D stays editing. C arms befor
cleanup, so a fast second C exits. Empty D cancels a responding turn without
pre-arming. At a pending approval, D preserves the confirmation and arms D/D exit;
C cancels it while retaining the exit window. Reuse the existing expiry/mixed-key
guard. Harness cleanup and exit-code differences are not graceful-exit parity.
guard. Confirmed keyboard exits use 0 in all three renderer/runtime profiles,
after the shared cleanup drain; actual OS-signal termination retains its signal
code. Historical 0/130 observations remain baseline evidence, not acceptance
of the mismatch. Harness cleanup is not a graceful exit.
Composer cancel registers one callback on the current live-turn sequence. Keep
the callback, structured queue pop, draft restore and start-level C/D dispatch
@@ -53,8 +53,10 @@ unmount 使迟到输出失效,不能覆盖 successor 或其附件。启动前
Ctrl+D 读取实时 composer:非空 D 继续编辑。C 在清理前 arm,快速第二 C
退出。响应中空 D 取消而不预先 arm。等待审批时 D 保留确认并 arm D/D 退出;
C 取消确认且保留退出窗口。复用既有过期/混合键 guard;harness 清理及退出码
差异不代表优雅退出对齐。
C 取消确认且保留退出窗口。复用既有过期/混合键 guard。确认的键盘退出在
三个 renderer/runtime profile 均须完成共享清理后以 0 退出;真实 OS signal
终止保留原信号码。历史 0/130 观测继续作为基线证据,不代表接受差异;
harness 清理不等于优雅退出。
Composer 取消在当前 live-turn sequence 注册一个回调。回调、结构化队列
pop、草稿恢复和入口 C/D 分派整体保留本批。Abort 的生成器收尾后,将返还
@@ -387,9 +387,7 @@ it.each(cases)(
'orderly PTY exit event',
10_000,
);
expect(active.exitResult()?.exitCode).toBe(
name === 'active-exit' && renderer === 'opentui' ? 130 : 0,
);
expect(active.exitResult()?.exitCode).toBe(0);
expect(modelServer.requests).toHaveLength(
['tool', 'allow', 'cancel-followup'].includes(name) ? 2 : 1,
);
@@ -701,9 +701,7 @@ it('ctrl-c-clear-then-exit', () =>
() => session.exitResult() !== undefined,
'second Ctrl+C must exit',
);
expect(session.exitResult()?.exitCode).toBe(
renderer === 'opentui' ? 130 : 0,
);
expect(session.exitResult()?.exitCode).toBe(0);
expect(server.requests).toHaveLength(0);
},
));
@@ -755,9 +753,7 @@ for (const key of ['c', 'd'] as const) {
() => session.exitResult() !== undefined,
'second exit key must exit',
);
expect(session.exitResult()?.exitCode).toBe(
renderer === 'opentui' ? 130 : 0,
);
expect(session.exitResult()?.exitCode).toBe(0);
expect(server.requests).toHaveLength(1);
},
100,
@@ -803,9 +799,7 @@ for (const key of ['c', 'd'] as const) {
() => session.exitResult() !== undefined,
'confirmation second key must exit',
);
expect(session.exitResult()?.exitCode).toBe(
renderer === 'opentui' ? 130 : 0,
);
expect(session.exitResult()?.exitCode).toBe(0);
expect(existsSync(sentinel)).toBe(false);
},
100,
@@ -19,15 +19,15 @@
* Every OpenTUI exit path (Ctrl+C/Ctrl+D double press, /quit, render-error
* 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.
* exits with the caller’s code. Confirmed keyboard exits and /quit use 0;
* actual OS-signal exits retain signal-style codes (130/143).
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import { exitCleanly } from '../../utils/processUtils.js';
/** Exit code for interrupt-style exits (Ctrl+C / Ctrl+D double press). */
/** Exit code for actual SIGINT-style termination, not confirmed keyboard exit. */
export const EXIT_CODE_INTERRUPT = 130;
/** Exit code for termination-style exits (SIGTERM semantics). */
export const EXIT_CODE_TERMINATED = 143;
@@ -791,6 +791,7 @@ describe('startOpenTuiUI fallback contract', () => {
});
it('reads live composer text for Ctrl+D and only arms exit after it becomes empty', async () => {
const config = buildConfig();
await withEntryApp(() => {
let text = 'draft';
const handle = mocks.state.appProps?.composerHandle;
@@ -813,31 +814,60 @@ describe('startOpenTuiUI fallback contract', () => {
press();
expect(exitSession).not.toHaveBeenCalled();
press();
expect(exitSession).toHaveBeenCalledTimes(1);
});
expect(exitSession).toHaveBeenCalledExactlyOnceWith(config, 0);
}, config);
});
it.each(['stream-c', 'confirmation-d'] as const)(
'arms before cleanup and exits on the second key: %s',
it.each(['c', 'd'] as const)(
'exits idle keyboard confirmation with code 0: %s',
async (name) => {
const config = buildConfig();
await withEntryApp(() => {
act(() => mocks.state.keyboard.at(-1)!({ name, ctrl: true }));
expect(exitSession).not.toHaveBeenCalled();
expect(mocks.state.interrupt).not.toHaveBeenCalled();
act(() => mocks.state.keyboard.at(-1)!({ name, ctrl: true }));
expect(exitSession).toHaveBeenCalledExactlyOnceWith(config, 0);
}, config);
},
);
it.each([
'stream-c',
'stream-d',
'confirmation-c',
'confirmation-d',
] as const)(
'preserves cancellation before confirmed graceful exit: %s',
async (mode) => {
const config = buildConfig();
const confirm = vi.fn(async () => {});
mocks.state.streaming = true;
if (mode === 'confirmation-d')
const parked = mode.startsWith('confirmation');
if (parked)
mocks.state.waitingCalls = [
{ callId: 'parked', confirmationDetails: { onConfirm: confirm } },
];
await withEntryApp(() => {
const name = mode === 'stream-c' ? 'c' : 'd';
await withEntryApp((rerender) => {
const name = mode.endsWith('c') ? 'c' : 'd';
act(() => mocks.state.keyboard.at(-1)!({ name, ctrl: true }));
expect(exitSession).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(mocks.state.interrupt).toHaveBeenCalledTimes(
mode === 'stream-c' ? 1 : 0,
expect(confirm).toHaveBeenCalledTimes(
mode === 'confirmation-c' ? 1 : 0,
);
expect(mocks.state.interrupt).toHaveBeenCalledTimes(parked ? 0 : 1);
if (mode === 'stream-d') {
mocks.state.streaming = false;
act(rerender);
act(() => mocks.state.keyboard.at(-1)!({ name, ctrl: true }));
expect(exitSession).not.toHaveBeenCalled();
}
act(() => mocks.state.keyboard.at(-1)!({ name, ctrl: true }));
expect(exitSession).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
});
expect(exitSession).toHaveBeenCalledExactlyOnceWith(config, 0);
expect(confirm).toHaveBeenCalledTimes(
mode === 'confirmation-c' ? 1 : 0,
);
}, config);
},
);
@@ -109,7 +109,7 @@ import type { LiveHistoryItem } from './live-session-model.js';
import { ensureConfigInitialized } from './live-session.js';
import { consumeLastRenderError } from './opentui-error-boundary.js';
import { createExitGuard, exitGuardHint } from './exit-guard.js';
import { EXIT_CODE_INTERRUPT, exitSession } from './exit-lifecycle.js';
import { exitSession } from './exit-lifecycle.js';
import { Command, matchesCommand } from './key-map.js';
import type { OpenTuiComposerHandle } from './input-submission.js';
import { buildUiHistoryItems } from './session-switch.js';
@@ -347,7 +347,7 @@ function OpenTuiEntryApp({
// Ink pre-arms C before cleanup; D only arms at an idle/confirmation boundary.
if (key.name === 'c' || parked || !streamingRef.current) {
if (exitGuard.press(guardKey) === 'exit') {
void exitSession(config, EXIT_CODE_INTERRUPT);
void exitSession(config, 0);
return;
}
setExitHint(exitGuardHint(guardKey));