Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b92fde1
feat(core): add a live-session registry and `qwen sessions ps`
qqqys Aug 8, 2026
88cb405
Merge branch 'main' into feat/session-registry
qqqys Aug 8, 2026
a9e9cee
test(cli): restore gemini.test.tsx mocks for the session registry
qqqys Aug 8, 2026
0f0af41
fix(core): keep the session-registry swap independent of the sidecar
qqqys Aug 8, 2026
dc42240
test: pin the session-registry wiring and the /proc starttime field
qqqys Aug 8, 2026
552dfbb
fix(core): do not sweep session records from another PID namespace
qqqys Aug 8, 2026
d62d196
fix(core): key session-registry trust on the record's origin
qqqys Aug 9, 2026
c1f7813
fix(core): reject the uninitialized machine-id sentinel
qqqys Aug 9, 2026
421774f
fix(core): stop noFollow writes and unprovable records from being tru…
qqqys Aug 9, 2026
2423dcf
fix(core): bind registry writes to the entry they validated
qqqys Aug 9, 2026
9c16eb1
fix(core): stop the registry's fs.constants read from failing module …
qqqys Aug 9, 2026
8621320
fix(core): stop registry reads hanging, over-reading, or clobbering s…
qqqys Aug 10, 2026
a5bac99
fix(core): close the three Criticals from review round 7
qqqys Aug 10, 2026
f9f3ddc
Merge branch 'main' into feat/session-registry
qwen-code-dev-bot Aug 10, 2026
124a96f
Merge branch 'main' into feat/session-registry
qwen-code-dev-bot Aug 10, 2026
040fad2
Merge branch 'main' into feat/session-registry
wenshao Aug 10, 2026
01d9d6a
fix(core): clear a non-directory squatting on the registry dir path
qqqys Aug 11, 2026
36b987a
fix(cli): stop ellipsizing names that fit, and close three test gaps
qqqys Aug 11, 2026
65661f0
fix(core): let listLiveSessions report that it could not look
qqqys Aug 11, 2026
375bb56
fix(core): clear directory blocking session registration
qqqys Aug 11, 2026
55cf254
fix(core): harden session registry cleanup
qqqys Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/cli/src/commands/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@

import { describe, it, expect, vi } from 'vitest';

// Subcommand modules are stubbed so this file tests wiring only — loading
// the real ones would pull the whole core barrel in behind them.
vi.mock('./sessions/list.js', () => ({
listCommand: {
command: 'list',
describe: 'List sessions',
},
}));

vi.mock('./sessions/ps.js', () => ({
psCommand: {
command: 'ps',
describe: 'List Qwen Code sessions running right now',
},
}));

import { sessionsCommand } from './sessions.js';
import { type Argv } from 'yargs';
import yargs from 'yargs';
Expand Down Expand Up @@ -42,7 +51,7 @@ describe('sessions command', () => {
expect(options.key).toHaveProperty('help');
});

it('should register list subcommand', () => {
it('should register list and ps subcommands', () => {
const mockYargs = {
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
Expand All @@ -55,12 +64,13 @@ describe('sessions command', () => {
}
builder(mockYargs as unknown as Argv);

expect(mockYargs.command).toHaveBeenCalledTimes(1);
expect(mockYargs.command).toHaveBeenCalledTimes(2);

const commandCalls = mockYargs.command.mock.calls;
const commandNames = commandCalls.map((call) => call[0].command);

expect(commandNames).toContain('list');
expect(commandNames).toContain('ps');

expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

import type { CommandModule, Argv } from 'yargs';
import { listCommand } from './sessions/list.js';
import { psCommand } from './sessions/ps.js';

export const sessionsCommand: CommandModule = {
command: 'sessions',
describe: 'Manage Qwen Code sessions',
builder: (yargs: Argv) =>
yargs
.command(listCommand)
.command(psCommand)
Comment on lines 16 to +17

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-2: issue #8724's work breakdown names this deliverable qwen ps ("PR 1 — session registry + qwen ps … the user-visible payoff"), but the PR ships it only as qwen sessions ps. Verified against the real CLI: the root parser registers $0 [query..], so qwen ps does not error as an unknown command — it is absorbed as a one-shot prompt and silently spends a model call on the prompt "ps" (qwen sessions ps prints the listing). — Failure scenario: a user or script following issue #8724 runs qwen ps and gets a model response to the prompt "ps" instead of the live-session listing; the issue's named payoff is unreachable under its own name, and worse than an error because it is silent.

中文说明

issue #8724 的工作拆分把这个交付物命名为 qwen ps("PR 1 — session registry + qwen ps ……用户可见的收益"),但 PR 只实现了 qwen sessions ps。已在真实 CLI 上验证:根解析器注册了 $0 [query..],所以 qwen ps 不会报未知命令错误——它被当作一次性 prompt 吸收,悄悄花掉一次模型调用(prompt 内容是 "ps");而 qwen sessions ps 才打印列表。失败场景:按 issue #8724 操作的用户或脚本运行 qwen ps,得到的是对 prompt "ps" 的模型回复而不是存活会话列表;issue 指定的入口以自己的名字不可达,而且比报错更糟——它是静默的。

— qwen3.8-max via Qwen Code /review (v0.21.8)

.demandCommand(1, 'You need at least one command before continuing.')
Comment on lines 16 to 18

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R10-3: The new user-facing subcommand qwen sessions ps ships with no entry in docs/users/features/commands.md, which documents the sibling qwen sessions list with a table row, flag table, output description, and examples; a repo-wide grep finds zero sessions ps hits in docs/, and this diff touches no docs/ files. — Concrete cost: users consulting the official command reference cannot discover qwen sessions ps or its --json flag; the docs drift from the shipped CLI surface is created at merge time. Suggested fix: add a qwen sessions ps row to the Session Management table and a sibling section mirroring sessions list (flags: --json; human table columns NAME/PID/AGE/DIRECTORY; JSON Lines record fields).

中文说明

R10-3:新的用户可见子命令 qwen sessions ps 发布时在 docs/users/features/commands.md 中没有任何条目,而该文档为同族的 qwen sessions list 记录了表格行、选项表、输出说明和示例;全仓库 grep 在 docs/ 中找不到任何 sessions ps,且本 diff 未触碰任何 docs/ 文件。具体代价:查阅官方命令参考的用户无法发现 qwen sessions ps 及其 --json 选项;文档与已发布 CLI 面的漂移在合并时即被制造。建议修复:在 Session Management 表格中新增 qwen sessions ps 行,并仿照 sessions list 增加小节(选项:--json;人类可读表列 NAME/PID/AGE/DIRECTORY;JSON Lines 记录字段)。

— qwen3.8-max via Qwen Code /review (v0.21.9)

.version(false),
// demandCommand(1) ensures a subcommand is always required;
Expand Down
225 changes: 225 additions & 0 deletions packages/cli/src/commands/sessions/ps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';

const listLiveSessions = vi.fn();

vi.mock('@qwen-code/qwen-code-core', () => ({
listLiveSessions: (...args: unknown[]) => listLiveSessions(...args),
}));

const stdout: string[] = [];
const stderr: string[] = [];

vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: (line: string) => stdout.push(line),
writeStderrLine: (line: string) => stderr.push(line),
}));

const { psCommand, formatAge, NAME_COL, PID_COL, AGE_COL } = await import(
'./ps.js'
);

function record(
over: Partial<SessionRegistryRecord> = {},
): SessionRegistryRecord {
return {
schemaVersion: 1,
pid: 4242,
procStart: '123',
pidNamespace: '4026531836',
machineId: 'test-machine',
sessionId: 'sess-1',
cwd: '/w/app',
name: 'app-ab',
kind: 'interactive',
startedAt: Date.now() - 90_000,
qwenVersion: '1.0.0',
peerProtocol: 1,
...over,
};
}

async function run(argv: Record<string, unknown>): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (psCommand.handler as any)(argv);
}

beforeEach(() => {
stdout.length = 0;
stderr.length = 0;
listLiveSessions.mockReset();
// Only Date: the age cell is rendered from `Date.now()` read inside the
// handler, against a `startedAt` this file computes when it builds the
// record, so any real delay between the two shifts the rendered age and
// fails an exact-row assertion for a reason that has nothing to do with
// what the test covers. Faking the timer queue too would stall the
// handler's own awaits, so the fake is scoped to the clock.
vi.useFakeTimers({ toFake: ['Date'] });
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe('formatAge', () => {
it('scales the unit with the magnitude', () => {
expect(formatAge(5_000)).toBe('5s');
expect(formatAge(90_000)).toBe('1m');
expect(formatAge(3 * 3600_000)).toBe('3h');
expect(formatAge(50 * 3600_000)).toBe('2d');
Comment on lines +74 to +76

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] None of formatAge's three unit thresholds is pinned: every fixture sits strictly inside a range (5s, 90s, 180min, 50h) and none lands on 60s / 60min / 24h. — Failure scenario: probe-verified — changing if (seconds < 60) to <= keeps the suite green while formatAge(60_000) returns '60s' instead of '1m' (similarly '60m' at 1h, '24h' at 1d) — the AGE column regresses to exactly the un-scannable values the function's doc exists to prevent.

Suggested change
expect(formatAge(90_000)).toBe('1m');
expect(formatAge(3 * 3600_000)).toBe('3h');
expect(formatAge(50 * 3600_000)).toBe('2d');
expect(formatAge(60_000)).toBe('1m');
expect(formatAge(3 * 3600_000)).toBe('3h');
expect(formatAge(50 * 3600_000)).toBe('2d');
expect(formatAge(24 * 3600_000)).toBe('1d');
中文说明

formatAge 的三个单位阈值一个都没有被固定:所有夹具都严格落在区间内部(5s、90s、180min、50h),没有一个落在 60s / 60min / 24h 边界上。失败场景:探针验证——把 if (seconds < 60) 改成 <=,套件仍绿,而 formatAge(60_000) 返回 '60s' 而不是 '1m'(1h 时返回 '60m'、1d 时返回 '24h' 同理)——AGE 列恰好退化回该函数文档要避免的不可扫读值。建议按上方补充边界夹具。

— qwen3.8-max via Qwen Code /review (v0.21.9)

});

it('clamps a record from the future to zero rather than showing a negative age', () => {
expect(formatAge(-10_000)).toBe('0s');
});
});

describe('qwen sessions ps', () => {
it('prints a table of live sessions', async () => {
listLiveSessions.mockResolvedValue([record()]);
await run({ json: false });

expect(stdout[0]).toMatch(/^NAME\s+PID\s+AGE\s+DIRECTORY$/);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The header row is pinned only up to \s+ between labels, so header column positions are unpinned while the data rows are pinned exactly — a header/row misalignment ships green. — Failure scenario: probe-verified — padDisplay('NAME', NAME_COL - 2) keeps the suite 12/12 green with the NAME header two columns left of the data cells; qwen sessions ps prints a visibly misaligned table.

Suggested change
expect(stdout[0]).toMatch(/^NAME\s+PID\s+AGE\s+DIRECTORY$/);
expect(stdout[0]).toBe(
'NAME'.padEnd(NAME_COL) + 'PID'.padEnd(PID_COL) + 'AGE'.padEnd(AGE_COL) + 'DIRECTORY',
);
中文说明

表头行只固定到标签之间的 \s+,因此表头各列位置未被固定,而数据行是精确固定的——表头与行错位可以绿着上线。失败场景:探针验证——padDisplay('NAME', NAME_COL - 2) 使套件保持 12/12 全绿,NAME 表头比数据单元格左移两列;qwen sessions ps 打印出肉眼可见错位的表格。建议按上方改为精确断言。

— qwen3.8-max via Qwen Code /review (v0.21.9)

expect(stdout[1]).toContain('app-ab');
expect(stdout[1]).toContain('4242');
expect(stdout[1]).toContain('/w/app');
});

it('says so plainly when nothing else is running', async () => {
listLiveSessions.mockResolvedValue([]);
await run({ json: false });
expect(stdout).toEqual(['No other Qwen Code sessions are running.']);
});

it('emits one JSON object per line with no header', async () => {
listLiveSessions.mockResolvedValue([record(), record({ pid: 7 })]);
await run({ json: true });

expect(stdout).toHaveLength(2);
expect(JSON.parse(stdout[0]).pid).toBe(4242);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-6 (carried forward from round 2 — still standing at this commit): the --json test pins only .pid per emitted line, so the full-record contract of the JSONL output is untested — production ps.ts emits JSON.stringify(record), and the complete SessionRegistryRecord is the machine-facing contract. — Concrete cost: a field dropped from or renamed in the emitted record (or the serialization format itself changing) leaves the test green while every scripting consumer of qwen sessions ps --json breaks. Assert the full record shape (or toMatchObject on the documented fields).

中文说明

R2-6(自第二轮携带——在当前提交仍然存在):--json 测试对每行输出只钉住 .pid,因此 JSONL 输出的完整记录契约未被测试——生产代码 ps.ts 输出 JSON.stringify(record),完整的 SessionRegistryRecord 才是面向机器的契约。具体代价:输出记录中被删掉或改名的字段(或序列化格式本身变化)不会让测试变红,而 qwen sessions ps --json 的所有脚本消费者都会坏掉。建议断言完整记录形状(或对文档化字段做 toMatchObject)。

— qwen3.8-max via Qwen Code /review (v0.21.8)

expect(JSON.parse(stdout[1]).pid).toBe(7);
Comment on lines +106 to +107

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The --json test pins only .pid per emitted line, so the full-record contract of the JSONL output is untested — production ps.ts emits JSON.stringify(record), and the complete SessionRegistryRecord is the machine-readable contract for scripts. — Failure scenario: probe-verified — the mutation JSON.stringify({ pid: record.pid, sessionId: record.sessionId }) keeps all 11 tests green while a consumer piping qwen sessions ps --json and reading .cwd, .name, .startedAt or .procStart silently gets undefined; the suggested strengthening makes the mutant fail.

Suggested change
expect(JSON.parse(stdout[0]).pid).toBe(4242);
expect(JSON.parse(stdout[1]).pid).toBe(7);
expect(JSON.parse(stdout[0])).toMatchObject({ pid: 4242, sessionId: 'sess-1', cwd: '/w/app', name: 'app-ab', kind: 'interactive', qwenVersion: '1.0.0' });
expect(JSON.parse(stdout[1]).pid).toBe(7);
中文说明

--json 测试对每行输出只断言了 .pid,因此 JSONL 输出的完整记录契约没有被测试——生产代码 ps.ts 输出 JSON.stringify(record),完整的 SessionRegistryRecord 才是面向脚本的机器可读契约。失败场景:已用探针验证——变异 JSON.stringify({ pid: record.pid, sessionId: record.sessionId }) 下全部 11 个测试仍然通过,而管道消费 qwen sessions ps --json 并读取 .cwd.name.startedAt.procStart 的脚本会悄悄得到 undefined;按建议加强断言后该变异会被捕获。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +105 to +107

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-6: the --json test pins only .pid per emitted line, so the full-record contract of the JSONL output is untested — production ps.ts emits JSON.stringify(record), and the complete SessionRegistryRecord is the machine-readable contract scripts consume. (carried forward from round 2 — still standing at this commit; the original thread remains open) — Failure scenario: a field rename/drop in the record ships with this test green; downstream scripts reading sessionId/cwd/machineId break silently.

中文说明

R2-6:--json 测试对每行输出只钉住 .pid,因此 JSONL 输出的完整记录契约未被测试——生产 ps.ts 输出 JSON.stringify(record),完整的 SessionRegistryRecord 才是脚本消费的机器可读契约。(第 2 轮遗留——在当前提交仍然存在;原线程仍开放。)失败场景:记录字段的改名/删除会随着该测试全绿出货;读取 sessionId/cwd/machineId 的下游脚本静默损坏。

— qwen3.8-max via Qwen Code /review (v0.21.8)

});

it('prints nothing on stdout for an empty JSON listing', async () => {
listLiveSessions.mockResolvedValue([]);
await run({ json: true });
expect(stdout).toEqual([]);
});

it('asks for the default listing, with no self-inclusion switch', async () => {
listLiveSessions.mockResolvedValue([]);

await run({ json: true });
expect(listLiveSessions).toHaveBeenLastCalledWith();
});

it('exposes no flag that claims to include this process', () => {
// `qwen sessions ps` runs and exits inside yargs' argument parsing, so
// it never reaches `startInteractiveUI` and never registers itself.
// A `--all` toggling `includeSelf` therefore had nothing to include:
// both settings produced identical output. Pinned here so it cannot
// come back without a registration to go with it.
const options: Record<string, unknown> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const yargsStub: any = {
option(name: string, config: unknown) {
options[name] = config;
return yargsStub;
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(psCommand.builder as any)(yargsStub);

expect(Object.keys(options)).toEqual(['json']);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R6-23: Nothing pins the --json option's default: false: the builder test asserts only option names, and every handler test passes explicit argv. — Failure scenario: mutation verified at this commit: flipping default: falsedefault: true leaves ps.test.ts + sessions.test.ts 15/15 green (sessions.test.ts stubs ps.js wholesale; handler tests pass explicit { json: false|true }; no other suite exercises the real yargs path), while bare qwen sessions ps silently changes from the human-readable table to raw JSON Lines.

Suggested change
expect(Object.keys(options)).toEqual(['json']);
expect(Object.keys(options)).toEqual(['json']);
expect(options['json']).toMatchObject({ type: 'boolean', default: false });
中文说明

没有任何测试钉住 --json 选项的 default: false:builder 测试只断言选项名,而每个 handler 测试都显式传 argv。失败场景:已在当前提交上做变异验证:把 default: false 翻转为 default: true,ps.test.ts + sessions.test.ts 仍为 15/15 绿色(sessions.test.ts 整体 stub 了 ps.js;handler 测试显式传 { json: false|true };没有其他套件行使真实 yargs 路径),而裸 qwen sessions ps 会静默从人类可读表格变为 JSON Lines 原始输出。

— qwen3.8-max via Qwen Code /review (v0.21.8)

});

it('neutralizes control sequences coming from another process record', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-17: sanitize's final control-byte regex — the sole defense against bare BEL/BS/VT/FF/SO–US/DEL and C1 bytes including the \x9b CSI introducer — has zero coverage: the only test uses fixtures (ESC CSI, CR, LF) fully handled by the first two passes. Probe-verified: deleting the final .replace(...) keeps 11/11 green, and deleting the escapeAnsiCtrlCodes call also stays green; a probe with bytes ansi-regex cannot match (\x07 \x08 \x7f \x0e \x9b) fails only when the final pass is removed. — Failure scenario: the final regex can silently regress: a record carrying a bare \x07 (bell) or \x08 (backspace overwriting prior table text) — possible under the shared-registry premise this review's Criticals rest on — would then reach the user's terminal.

Suggested fix: add fixture bytes ansi-regex cannot match and pass 1 doesn't strip, e.g. record({ name: 'a\x07b\x08c\x7fd', cwd: '/w/\x0ee' }), and assert the row contains none of them.

中文说明

sanitize 的最后一段控制字节正则——对抗裸 BEL/BS/VT/FF/SO–US/DEL 以及含 \x9b CSI 引导符在内的 C1 字节的唯一防线——覆盖为零:唯一的测试使用的夹具(ESC CSI、CR、LF)前两段就已全部处理。探针验证:删掉最后的 .replace(...) 仍 11/11 全绿,删掉 escapeAnsiCtrlCodes 调用也仍为绿;只有移除最后一段时,使用 ansi-regex 无法匹配的字节(\x07 \x08 \x7f \x0e \x9b)的探针才会失败。失败场景:最后这段正则可能被悄悄回归:在本评审各 Critical 所依据的共享注册表前提下,一条携带裸 \x07(响铃)或 \x08(退格覆盖先前表格文本)的记录将直达用户终端。

— qwen3.8-max via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R3-17: sanitize's final control-byte regex — the sole defense against bare BEL/BS/VT/FF/SO–US/DEL and C1 bytes including the \x9b CSI introducer — has zero coverage: the only test uses fixtures (ESC CSI, CR, LF) fully handled by the earlier pipeline stages. (carried forward from round 3 — still standing at this commit; the original thread remains open) — Failure scenario: deleting or narrowing the final .replace(...) keeps this test green; a peer record whose name contains a raw \x9b sequence then renders live ANSI in the table.

中文说明

R3-17:sanitize 最后的控制字节正则——对裸 BEL/BS/VT/FF/SO–US/DEL 以及包括 \x9b CSI 引导符在内的 C1 字节的唯一防御——覆盖为零:唯一的测试使用的 fixture(ESC CSI、CR、LF)全部被前面的处理阶段解决。(第 3 轮遗留——在当前提交仍然存在;原线程仍开放。)失败场景:删除或收窄最后的 .replace(...) 该测试保持绿;name 含裸 \x9b 序列的同伴记录随后会在表格中渲染出活的 ANSI。

— qwen3.8-max via Qwen Code /review (v0.21.8)

listLiveSessions.mockResolvedValue([
Comment thread
qqqys marked this conversation as resolved.
record({ name: 'ev\x1b[31mil\r', cwd: '/w/a\nb' }),
]);
Comment on lines +144 to +146

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This sanitization fixture pins \x1b, \r, and \n but never TAB, although TAB stripping lives solely in sanitize's first regex — the other two passes provably cannot catch it (escapeAnsiCtrlCodes matches only ANSI sequences; the C0/C1 regex excludes 0x09). Probe-verified: regressing the regex to /[\r\n]/g keeps the suite 12/12 green while a TAB-pinning probe fails with a raw TAB in the row. — Failure scenario: a live record whose name/cwd contains a TAB reaches the terminal raw, jumping to the next tab stop and misaligning or overpainting the PID/AGE/DIRECTORY columns — the exact breakage this file elsewhere claims to pin.

Suggested change
listLiveSessions.mockResolvedValue([
record({ name: 'ev\x1b[31mil\r', cwd: '/w/a\nb' }),
]);
listLiveSessions.mockResolvedValue([
record({ name: 'ev\x1b[31mil\r\t', cwd: '/w/a\nb' }),
]);
中文说明

这个清洗测试夹具固定了 \x1b\r\n,但从未固定 TAB;而 TAB 的剥离只存在于 sanitize 的第一个正则中——另外两道处理从机制上就抓不到它(escapeAnsiCtrlCodes 只匹配 ANSI 序列;C0/C1 正则排除了 0x09)。探针验证:把正则退化为 /[\r\n]/g,套件仍然 12/12 全绿,而带 TAB 固定的探针会在行中出现原始 TAB 而失败。失败场景:name/cwd 含 TAB 的存活记录原样到达终端,跳到下一个制表位,打乱或覆盖 PID/AGE/DIRECTORY 列——正是本文件其他地方声称要固定的破坏。建议按上方 suggestion 在夹具中加入 \t

— qwen3.8-max via Qwen Code /review (v0.21.9)

await run({ json: false });

const row = stdout[1];
expect(row).not.toContain('\x1b');
expect(row).not.toContain('\r');
expect(row).not.toContain('\n');
});

it('truncates an over-long name instead of breaking the columns', async () => {
listLiveSessions.mockResolvedValue([record({ name: 'x'.repeat(80) })]);
await run({ json: false });
expect(stdout[1]).toContain('...');
expect(stdout[1]).toContain('4242');
});

it('renders a name that exactly fills the column without an ellipsis', async () => {
// The band a budget of NAME_COL - 2 got wrong: a name this long fits
// its cell, and ellipsizing it both claims a truncation that did not
// happen and eats the hash suffix that distinguishes two sessions
// started in the same directory.
const name = 'a'.repeat(NAME_COL - 3) + '-7f';
expect(name).toHaveLength(NAME_COL);
listLiveSessions.mockResolvedValue([record({ name })]);
await run({ json: false });

expect(stdout[1]).toContain(name);
expect(stdout[1]).not.toContain('...');
// Still a column, not a collision: the cell keeps a separator from the
// PID beside it even when the name uses every one of its columns.
expect(stdout[1]).toContain(`${name} 4242`);
});

it('cuts a multi-width name on a character boundary, not a column one', async () => {
// 15 full-width characters is 30 display columns against the NAME_COL
// budget. Subtracting the three columns "..." costs leaves 19: the
// ninth character ends at column 18, and a tenth would straddle the
// limit, so the cut lands at nine characters. Asserting the cell
// exactly is what pins the accumulation loop — "contains ..." survives
// a loop that copies nothing at all.
//
// The sibling cells are derived from the exported widths rather than
// spelled out: they have nothing to do with truncation, and hardcoding
// their padding would fail this test for a column-width change it does
// not test. The age is pinned by the frozen clock in `beforeEach`, not
// by wall time — read from `Date.now()` inside the handler, a real
// delay between the record's `startedAt` and that call would render
// "2m" and fail here for a reason that is not truncation.
listLiveSessions.mockResolvedValue([record({ name: '中'.repeat(15) })]);
await run({ json: false });

const cell = '中'.repeat(9) + '...';
const pad = (text: string, width: number) =>
text + ' '.repeat(width - text.length);
expect(stdout[1]).toBe(
[
cell + ' '.repeat(NAME_COL - 21),
pad('4242', PID_COL),
pad('1m', AGE_COL),
'/w/app',
].join(' '),
);
});

it('reports a registry read failure on stderr and exits non-zero', async () => {
listLiveSessions.mockRejectedValue(new Error('registry on fire'));
const exit = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);

await run({ json: false });

expect(stderr).toEqual([
'Error: failed to read the session registry: registry on fire',
]);
expect(exit).toHaveBeenCalledWith(1);
// Nothing is printed once the listing failed — not even the header.
expect(stdout).toEqual([]);
});
});
Loading
Loading