-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(core): add a live-session registry and qwen sessions ps
#8728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b92fde1
88cb405
a9e9cee
0f0af41
dc42240
552dfbb
d62d196
c1f7813
421774f
2423dcf
9c16eb1
8621320
a5bac99
f9f3ddc
124a96f
040fad2
01d9d6a
36b987a
65661f0
375bb56
55cf254
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| .demandCommand(1, 'You need at least one command before continuing.') | ||
|
Comment on lines
16
to
18
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R10-3: The new user-facing subcommand 中文说明R10-3:新的用户可见子命令 — qwen3.8-max via Qwen Code /review (v0.21.9) |
||
| .version(false), | ||
| // demandCommand(1) ensures a subcommand is always required; | ||
|
|
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] None of
Suggested change
中文说明
— 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$/); | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The header row is pinned only up to
Suggested change
中文说明表头行只固定到标签之间的 — 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); | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 中文说明R2-6(自第二轮携带——在当前提交仍然存在): — qwen3.8-max via Qwen Code /review (v0.21.8) |
||||||||||||||||
| expect(JSON.parse(stdout[1]).pid).toBe(7); | ||||||||||||||||
|
Comment on lines
+106
to
+107
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The
Suggested change
中文说明
— qwen3.8-max via Qwen Code /review (v0.21.7)
Comment on lines
+105
to
+107
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R2-6: the 中文说明R2-6: — 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']); | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R6-23: Nothing pins the
Suggested change
中文说明没有任何测试钉住 — qwen3.8-max via Qwen Code /review (v0.21.8) |
||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| it('neutralizes control sequences coming from another process record', async () => { | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R3-17: Suggested fix: add fixture bytes ansi-regex cannot match and pass 1 doesn't strip, e.g. 中文说明
— qwen3.8-max via Qwen Code /review (v0.21.8)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R3-17: 中文说明R3-17: — qwen3.8-max via Qwen Code /review (v0.21.8) |
||||||||||||||||
| listLiveSessions.mockResolvedValue([ | ||||||||||||||||
|
qqqys marked this conversation as resolved.
|
||||||||||||||||
| record({ name: 'ev\x1b[31mil\r', cwd: '/w/a\nb' }), | ||||||||||||||||
| ]); | ||||||||||||||||
|
Comment on lines
+144
to
+146
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This sanitization fixture pins
Suggested change
中文说明这个清洗测试夹具固定了 — 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([]); | ||||||||||||||||
| }); | ||||||||||||||||
| }); | ||||||||||||||||
There was a problem hiding this comment.
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 asqwen sessions ps. Verified against the real CLI: the root parser registers$0 [query..], soqwen psdoes 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 psprints the listing). — Failure scenario: a user or script following issue #8724 runsqwen psand 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)