Skip to content
Merged
61 changes: 43 additions & 18 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import type { LoadedSettings } from './config/settings.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
import { filterCommandsForMode } from './services/commandUtils.js';
import { _resetCleanupFunctionsForTest } from './utils/cleanup.js';
import { AlreadyReportedError, _resetExitLatchForTest } from './utils/errors.js';
import {
AlreadyReportedError,
_resetExitLatchForTest,
} from './utils/errors.js';

// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/ui/commands/clearCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('clearCommand', () => {
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: mockResetBackgroundShells,
abortAll: mockAbortBackgroundShells,
}),
Expand Down Expand Up @@ -307,6 +308,7 @@ describe('clearCommand', () => {
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: mockResetBackgroundShells,
abortAll: mockAbortBackgroundShells,
}),
Expand Down Expand Up @@ -380,6 +382,7 @@ describe('clearCommand', () => {
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: vi.fn().mockReturnValue({
Expand Down Expand Up @@ -430,6 +433,7 @@ describe('clearCommand', () => {
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: vi.fn().mockReturnValue({
Expand Down Expand Up @@ -470,5 +474,61 @@ describe('clearCommand', () => {
expect(mockStartNewSession).not.toHaveBeenCalled();
expect(mockResetChat).not.toHaveBeenCalled();
});

it('blocks session clearing while a background shell is still running', async () => {
if (!clearCommand.action)
throw new Error('clearCommand must have an action.');

const blockedContext = createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([
{
shellId: 'shell_123',
status: 'running',
},
]),
hasRunningEntries: vi.fn().mockReturnValue(true),
reset: vi.fn(),
}),
getMonitorRegistry: vi.fn().mockReturnValue({
getRunning: vi.fn().mockReturnValue([]),
reset: vi.fn(),
}),
getHookSystem: mockGetHookSystem,
startNewSession: mockStartNewSession,
getGeminiClient: vi.fn().mockReturnValue({
resetChat: mockResetChat,
} as unknown as GeminiClient),
getModel: vi.fn().mockReturnValue('test-model'),
getApprovalMode: vi.fn().mockReturnValue('default'),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
}),
getDebugLogger: vi.fn().mockReturnValue({ warn: vi.fn() }),
},
},
session: {
startNewSession: vi.fn(),
},
});

const result = await clearCommand.action(blockedContext, '');

expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
"Stop the current session's running background tasks before starting a new session.",
});
expect(mockStartNewSession).not.toHaveBeenCalled();
expect(mockResetChat).not.toHaveBeenCalled();
});
});
});
22 changes: 4 additions & 18 deletions packages/cli/src/ui/commands/clearCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,12 @@ import {
SessionEndReason,
SessionStartSource,
ToolNames,
type Config,
type PermissionMode,
} from '@qwen-code/qwen-code-core';

function hasBlockingBackgroundWork(config: Config): boolean {
return (
config.getBackgroundTaskRegistry().hasUnfinalizedTasks() ||
config.getMonitorRegistry().getRunning().length > 0 ||
config
.getBackgroundShellRegistry()
.getAll()
.some((entry) => entry.status === 'running')
);
}

function resetBackgroundStateForSessionSwitch(config: Config): void {
config.getBackgroundTaskRegistry().reset();
config.getMonitorRegistry().reset();
config.getBackgroundShellRegistry().reset();
}
import {
hasBlockingBackgroundWork,
resetBackgroundStateForSessionSwitch,
} from '../utils/backgroundWorkUtils.js';

export const clearCommand: SlashCommand = {
name: 'clear',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/hooks/useResumeCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ describe('useResumeCommand', () => {
}),
getBackgroundShellRegistry: () => ({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
Expand Down Expand Up @@ -248,6 +249,7 @@ describe('useResumeCommand', () => {
}),
getBackgroundShellRegistry: () => ({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
Expand Down Expand Up @@ -306,6 +308,7 @@ describe('useResumeCommand', () => {
}),
getBackgroundShellRegistry: () => ({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
Expand Down Expand Up @@ -364,6 +367,7 @@ describe('useResumeCommand', () => {
}),
getBackgroundShellRegistry: () => ({
getAll: vi.fn().mockReturnValue([]),
hasRunningEntries: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
Expand Down
21 changes: 4 additions & 17 deletions packages/cli/src/ui/hooks/useResumeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type HistoryItem } from '../types.js';
import {
hasBlockingBackgroundWork,
resetBackgroundStateForSessionSwitch,
} from '../utils/backgroundWorkUtils.js';

export interface UseResumeCommandOptions {
config: Config | null;
Expand Down Expand Up @@ -45,23 +49,6 @@ export interface UseResumeCommandResult {
const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE =
"Stop the current session's running background tasks before resuming another session.";

function hasBlockingBackgroundWork(config: Config): boolean {
return (
config.getBackgroundTaskRegistry().hasUnfinalizedTasks() ||
config.getMonitorRegistry().getRunning().length > 0 ||
config
.getBackgroundShellRegistry()
.getAll()
.some((entry) => entry.status === 'running')
);
}

function resetBackgroundStateForSessionSwitch(config: Config): void {
config.getBackgroundTaskRegistry().reset();
config.getMonitorRegistry().reset();
config.getBackgroundShellRegistry().reset();
}

export function useResumeCommand(
options?: UseResumeCommandOptions,
): UseResumeCommandResult {
Expand Down
116 changes: 116 additions & 0 deletions packages/cli/src/ui/utils/backgroundWorkUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import {
hasBlockingBackgroundWork,
resetBackgroundStateForSessionSwitch,
} from './backgroundWorkUtils.js';

function createMockConfig(overrides?: {
hasUnfinalizedTasks?: boolean;
runningMonitors?: unknown[];
hasRunningEntries?: boolean;
}): Config {
return {
getBackgroundTaskRegistry: () => ({
hasUnfinalizedTasks: () => overrides?.hasUnfinalizedTasks ?? false,
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
getRunning: () => overrides?.runningMonitors ?? [],
reset: vi.fn(),
}),
getBackgroundShellRegistry: () => ({
hasRunningEntries: () => overrides?.hasRunningEntries ?? false,
reset: vi.fn(),
}),
} as unknown as Config;
}

describe('hasBlockingBackgroundWork', () => {
it('returns false when nothing is running', () => {
expect(hasBlockingBackgroundWork(createMockConfig())).toBe(false);
});

it('returns true when background tasks are unfinalized', () => {
expect(
hasBlockingBackgroundWork(
createMockConfig({ hasUnfinalizedTasks: true }),
),
).toBe(true);
});

it('returns true when monitors are running', () => {
expect(
hasBlockingBackgroundWork(
createMockConfig({ runningMonitors: [{ id: 'm1' }] }),
),
).toBe(true);
});

it('returns true when shell entries are running', () => {
expect(
hasBlockingBackgroundWork(createMockConfig({ hasRunningEntries: true })),
).toBe(true);
});

it('short-circuits: does not check monitors or shells when tasks are unfinalized', () => {
const config = {
getBackgroundTaskRegistry: () => ({
hasUnfinalizedTasks: () => true,
reset: vi.fn(),
}),
getMonitorRegistry: () => {
throw new Error('should not be called');
},
getBackgroundShellRegistry: () => {
throw new Error('should not be called');
},
} as unknown as Config;

expect(hasBlockingBackgroundWork(config)).toBe(true);
});

it('short-circuits: does not check shells when monitors are running', () => {
const config = {
getBackgroundTaskRegistry: () => ({
hasUnfinalizedTasks: () => false,
reset: vi.fn(),
}),
getMonitorRegistry: () => ({
getRunning: () => [{ id: 'm1' }],
reset: vi.fn(),
}),
getBackgroundShellRegistry: () => {
throw new Error('should not be called');
},
} as unknown as Config;

expect(hasBlockingBackgroundWork(config)).toBe(true);
});
});

describe('resetBackgroundStateForSessionSwitch', () => {
it('calls reset on all three registries', () => {
const resetTasks = vi.fn();
const resetMonitors = vi.fn();
const resetShells = vi.fn();

const config = {
getBackgroundTaskRegistry: () => ({ reset: resetTasks }),
getMonitorRegistry: () => ({ reset: resetMonitors }),
getBackgroundShellRegistry: () => ({ reset: resetShells }),
} as unknown as Config;

resetBackgroundStateForSessionSwitch(config);

expect(resetTasks).toHaveBeenCalledOnce();
expect(resetMonitors).toHaveBeenCalledOnce();
expect(resetShells).toHaveBeenCalledOnce();
});
});
21 changes: 21 additions & 0 deletions packages/cli/src/ui/utils/backgroundWorkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import type { Config } from '@qwen-code/qwen-code-core';

export function hasBlockingBackgroundWork(config: Config): boolean {

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] 提取到共享模块的 hasBlockingBackgroundWork 缺少 JSDoc。建议添加说明其检查顺序和阻塞语义:

Suggested change
export function hasBlockingBackgroundWork(config: Config): boolean {
/**
* Returns true when any background work is still in progress that would
* conflict with starting or resuming a session.
*
* Checks in short-circuit order: unfinalized tasks, running monitors,
* then running background shells.
*/
export function hasBlockingBackgroundWork(config: Config): boolean {

— deepseek-v4-pro via Qwen Code /review

return (
config.getBackgroundTaskRegistry().hasUnfinalizedTasks() ||
config.getMonitorRegistry().getRunning().length > 0 ||
config.getBackgroundShellRegistry().hasRunningEntries()
);
}
Comment thread
doudouOUC marked this conversation as resolved.

export function resetBackgroundStateForSessionSwitch(config: Config): void {
config.getBackgroundTaskRegistry().reset();
config.getMonitorRegistry().reset();
config.getBackgroundShellRegistry().reset();
}
3 changes: 2 additions & 1 deletion packages/cli/src/utils/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ describe('errors', () => {
{
error: {
type: 'AlreadyReportedError',
message: '[API Error: 402 Model X is not available for billing.]',
message:
'[API Error: 402 Model X is not available for billing.]',
code: 42,
},
},
Expand Down
Loading
Loading