Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 7 additions & 5 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,11 +1220,13 @@ export async function loadCliConfig(
: settings.tools?.discoveryCommand,
toolCallCommand: bareMode ? undefined : settings.tools?.callCommand,
mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand,
mcpServers: bareMode ? {} : (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
mcpServers: bareMode
? {}
: (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,21 @@ export const AppContainer = (props: AppContainerProps) => {
);
historyManager.loadHistory(historyItems);

const recovered = await config.loadPausedBackgroundAgents(
config.getSessionId(),
);
if (recovered.length > 0) {
historyManager.addItem(
{
type: MessageType.INFO,
text: config
.getBackgroundAgentResumeService()
.buildRecoveredBackgroundAgentsNotice(recovered.length),
},
Date.now(),
);
}

// Restore session name tag from custom title
const title = config
.getSessionService()
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/src/ui/commands/clearCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe('clearCommand', () => {
({
resetChat: mockResetChat,
}) as unknown as GeminiClient,
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([]),
reset: vi.fn(),
}),
startNewSession: mockStartNewSession,
getHookSystem: mockGetHookSystem,
getDebugLogger: () => ({
Expand Down Expand Up @@ -237,6 +245,14 @@ describe('clearCommand', () => {
services: {
config: {
getHookSystem: mockGetHookSystem,
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: vi.fn().mockReturnValue([]),
reset: vi.fn(),
}),
startNewSession: mockStartNewSession,
getGeminiClient: vi.fn().mockReturnValue({
resetChat: mockResetChat,
Expand Down Expand Up @@ -287,5 +303,51 @@ describe('clearCommand', () => {
);
expect(mockFireSessionStartEvent).toHaveBeenCalled();
});

it('blocks session clearing while background work 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(true),
reset: vi.fn(),
}),
getBackgroundShellRegistry: vi.fn().mockReturnValue({
getAll: 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();
});
});
});
31 changes: 31 additions & 0 deletions packages/cli/src/ui/commands/clearCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,25 @@ import {
SessionEndReason,
SessionStartSource,
ToolNames,
type Config,
type PermissionMode,
} from '@qwen-code/qwen-code-core';

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

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

export const clearCommand: SlashCommand = {
name: 'clear',
altNames: ['reset', 'new'],
Expand All @@ -27,6 +43,20 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;

if (config) {
if (hasBlockingBackgroundWork(config)) {
const content =
"Stop the current session's running background tasks before starting a new session.";
context.ui.setDebugMessage(content);
if (context.executionMode !== 'interactive') {
return {
type: 'message' as const,
messageType: 'error' as const,
content,
};
}
return;
}

// Fire SessionEnd event (non-blocking to avoid UI lag)
config
.getHookSystem()
Expand All @@ -35,6 +65,7 @@ export const clearCommand: SlashCommand = {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
});

resetBackgroundStateForSessionSwitch(config);
const newSessionId = config.startNewSession();

// Reset UI telemetry metrics for the new session
Expand Down
82 changes: 74 additions & 8 deletions packages/cli/src/ui/commands/tasksCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { tasksCommand } from './tasksCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core';
import type {
BackgroundShellEntry,
BackgroundTaskEntry,
} from '@qwen-code/qwen-code-core';

type AgentTaskTestEntry = BackgroundTaskEntry & {
resumeBlockedReason?: string;
};

function entry(
overrides: Partial<BackgroundShellEntry> = {},
Expand All @@ -25,16 +32,33 @@ function entry(
};
}

function agentEntry(
overrides: Partial<AgentTaskTestEntry> = {},
): AgentTaskTestEntry {
return {
agentId: 'agent_aaaaaaaa',
description: 'Investigate flaky test failure',
subagentType: 'researcher',
status: 'running',
startTime: Date.now() - 7_000,
abortController: new AbortController(),
...overrides,
};
}

describe('tasksCommand', () => {
let context: CommandContext;
let getAll: ReturnType<typeof vi.fn>;
let getShells: ReturnType<typeof vi.fn>;
let getAgents: ReturnType<typeof vi.fn>;

beforeEach(() => {
getAll = vi.fn().mockReturnValue([]);
getShells = vi.fn().mockReturnValue([]);
getAgents = vi.fn().mockReturnValue([]);
context = createMockCommandContext({
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll }),
getBackgroundShellRegistry: () => ({ getAll: getShells }),
getBackgroundTaskRegistry: () => ({ getAll: getAgents }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
Expand All @@ -45,12 +69,12 @@ describe('tasksCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No background shells.',
content: 'No background tasks.',
});
});

it('lists running and terminal entries with status / runtime / output path', async () => {
getAll.mockReturnValue([
it('lists running and terminal shell entries with status / runtime / output path', async () => {
getShells.mockReturnValue([
entry({
shellId: 'bg_run',
command: 'npm run dev',
Expand Down Expand Up @@ -81,7 +105,7 @@ describe('tasksCommand', () => {
if (!result || result.type !== 'message') {
throw new Error('expected message result');
}
expect(result.content).toContain('Background shells (3 total)');
expect(result.content).toContain('Background tasks (3 total)');
expect(result.content).toContain('[bg_run] running');
expect(result.content).toContain('pid=1111');
expect(result.content).toContain('npm run dev');
Expand All @@ -91,4 +115,46 @@ describe('tasksCommand', () => {
'output: /tmp/tasks/sess/shell-bg_done.output',
);
});

it('includes background agent entries alongside shells', async () => {
getAgents.mockReturnValue([
agentEntry({
agentId: 'agent_run',
description: 'Fix flaky test and send patch',
subagentType: 'researcher',
status: 'running',
outputFile: '/tmp/tasks/sess/agent_run.jsonl',
}),
agentEntry({
agentId: 'agent_pause',
description: 'Resume-safe task',
subagentType: 'researcher',
status: 'paused',
resumeBlockedReason: 'Subagent "researcher" is no longer available.',
}),
]);
getShells.mockReturnValue([
entry({
shellId: 'bg_shell',
command: 'npm run dev',
status: 'running',
}),
]);

const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') {
throw new Error('expected message result');
}

expect(result.content).toContain('Background tasks (3 total)');
expect(result.content).toContain('[agent_run] running');
expect(result.content).toContain(
'researcher: Fix flaky test and send patch',
);
expect(result.content).toContain('output: /tmp/tasks/sess/agent_run.jsonl');
expect(result.content).toContain(
'[agent_pause] paused (resume blocked): Subagent "researcher" is no longer available.',
);
expect(result.content).toContain('[bg_shell] running');
});
});
Loading
Loading