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
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { tasksCommand } from '../ui/commands/tasksCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
Expand Down Expand Up @@ -92,6 +93,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentsCommand,
tasksCommand,
arenaCommand,
approvalModeCommand,
authCommand,
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/ui/commands/tasksCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

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';

function entry(
overrides: Partial<BackgroundShellEntry> = {},
): BackgroundShellEntry {
return {
shellId: 'bg_aaaaaaaa',
command: 'sleep 60',
cwd: '/tmp',
status: 'running',
startTime: Date.now() - 5_000,
outputPath: '/tmp/tasks/sess/shell-bg_aaaaaaaa.output',
abortController: new AbortController(),
...overrides,
};
}

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

beforeEach(() => {
getAll = vi.fn().mockReturnValue([]);
context = createMockCommandContext({
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
});

it('reports an empty registry', async () => {
const result = await tasksCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No background shells.',
});
});

it('lists running and terminal entries with status / runtime / output path', async () => {
getAll.mockReturnValue([
entry({
shellId: 'bg_run',
command: 'npm run dev',
status: 'running',
startTime: Date.now() - 12_000,
pid: 1111,
}),
entry({
shellId: 'bg_done',
command: 'npm test',
status: 'completed',
exitCode: 0,
startTime: Date.now() - 70_000,
endTime: Date.now() - 5_000,
outputPath: '/tmp/tasks/sess/shell-bg_done.output',
}),
entry({
shellId: 'bg_fail',
command: 'flaky.sh',
status: 'failed',
error: 'spawn ENOENT',
startTime: Date.now() - 3_000,
endTime: Date.now() - 2_000,
}),
]);

const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') {
throw new Error('expected message result');
}
expect(result.content).toContain('Background shells (3 total)');
expect(result.content).toContain('[bg_run] running');
expect(result.content).toContain('pid=1111');
expect(result.content).toContain('npm run dev');
expect(result.content).toContain('[bg_done] completed (exit 0)');
expect(result.content).toContain('[bg_fail] failed: spawn ENOENT');
expect(result.content).toContain(
'output: /tmp/tasks/sess/shell-bg_done.output',
);
});
});
78 changes: 78 additions & 0 deletions packages/cli/src/ui/commands/tasksCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import { formatDuration } from '../utils/formatters.js';

function statusLabel(entry: BackgroundShellEntry): string {
switch (entry.status) {
case 'completed':
return `completed (exit ${entry.exitCode ?? '?'})`;
case 'failed':
return `failed: ${entry.error ?? 'unknown error'}`;
case 'cancelled':
return 'cancelled';
case 'running':
return 'running';
default:
return entry.status;
}
}

export const tasksCommand: SlashCommand = {
name: 'tasks',
get description() {
return t('List background tasks');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context) => {
const { config } = context.services;
if (!config) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: 'Config not available.',
};
}
Comment thread
wenshao marked this conversation as resolved.

const entries = config.getBackgroundShellRegistry().getAll();

if (entries.length === 0) {
return {
type: 'message' as const,
messageType: 'info' as const,
content: 'No background shells.',
};
}

const now = Date.now();
const lines: string[] = [
`Background shells (${entries.length} total)`,
'',
];
for (const entry of entries) {
const endTime = entry.endTime ?? now;
const runtime = formatDuration(endTime - entry.startTime, {
hideTrailingZeros: true,
});
const pidPart = entry.pid !== undefined ? ` pid=${entry.pid}` : '';
lines.push(
`[${entry.shellId}] ${statusLabel(entry)} ${runtime}${pidPart} ${entry.command}`,
);
lines.push(` output: ${entry.outputPath}`);
}

return {
type: 'message' as const,
messageType: 'info' as const,
content: lines.join('\n'),
};
},
};
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { PermissionManager } from '../permissions/permission-manager.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js';
import {
DEFAULT_OTLP_ENDPOINT,
DEFAULT_TELEMETRY_TARGET,
Expand Down Expand Up @@ -545,6 +546,7 @@ export class Config {
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
Comment thread
wenshao marked this conversation as resolved.
private readonly backgroundTaskRegistry = new BackgroundTaskRegistry();
private readonly backgroundShellRegistry = new BackgroundShellRegistry();
private extensionManager!: ExtensionManager;
private skillManager: SkillManager | null = null;
private permissionManager: PermissionManager | null = null;
Expand Down Expand Up @@ -1607,6 +1609,7 @@ export class Config {
}

this.backgroundTaskRegistry.abortAll();
this.backgroundShellRegistry.abortAll();

await this.cleanupArenaRuntime();
} catch (error) {
Expand Down Expand Up @@ -2486,6 +2489,10 @@ export class Config {
return this.backgroundTaskRegistry;
}

getBackgroundShellRegistry(): BackgroundShellRegistry {
return this.backgroundShellRegistry;
}

/**
* Whether interactive permission prompts should be auto-denied.
* True for background agents that have no UI to show prompts.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export * from './services/sessionService.js';
export * from './services/sessionTitle.js';
export { stripTerminalControlSequences } from './utils/terminalSafe.js';
export * from './services/shellExecutionService.js';
export * from './services/backgroundShellRegistry.js';
export * from './services/toolUseSummary.js';
export * from './utils/bareMode.js';

Expand Down
159 changes: 159 additions & 0 deletions packages/core/src/services/backgroundShellRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import {
BackgroundShellRegistry,
type BackgroundShellEntry,
} from './backgroundShellRegistry.js';

function makeEntry(
overrides: Partial<BackgroundShellEntry> = {},
): BackgroundShellEntry {
return {
shellId: 's1',
command: 'sleep 60',
cwd: '/tmp',
status: 'running',
startTime: 1000,
outputPath: '/tmp/s1.output',
abortController: new AbortController(),
...overrides,
};
}

describe('BackgroundShellRegistry', () => {
describe('register / get / getAll', () => {
it('round-trips a registered entry by id', () => {
const reg = new BackgroundShellRegistry();
const e = makeEntry({ shellId: 'a' });
reg.register(e);
expect(reg.get('a')).toBe(e);
});

it('returns undefined for unknown id', () => {
const reg = new BackgroundShellRegistry();
expect(reg.get('missing')).toBeUndefined();
});

it('lists all entries via getAll', () => {
const reg = new BackgroundShellRegistry();
const a = makeEntry({ shellId: 'a' });
const b = makeEntry({ shellId: 'b' });
reg.register(a);
reg.register(b);
const all = reg.getAll();
expect(all).toHaveLength(2);
expect(all).toContain(a);
expect(all).toContain(b);
});
});

describe('complete', () => {
it('transitions running → completed with exitCode and endTime', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.complete('a', 0, 2000);
const e = reg.get('a')!;
expect(e.status).toBe('completed');
expect(e.exitCode).toBe(0);
expect(e.endTime).toBe(2000);
});

it('is a no-op when entry is not running', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.cancel('a', 1500);
reg.complete('a', 0, 2000);
const e = reg.get('a')!;
expect(e.status).toBe('cancelled');
expect(e.exitCode).toBeUndefined();
});

it('is a no-op for unknown id', () => {
const reg = new BackgroundShellRegistry();
expect(() => reg.complete('missing', 0, 0)).not.toThrow();
});
});

describe('fail', () => {
it('transitions running → failed with error and endTime', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.fail('a', 'spawn error', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('failed');
expect(e.error).toBe('spawn error');
expect(e.endTime).toBe(2000);
});

it('is a no-op when entry is not running', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.complete('a', 0, 1500);
reg.fail('a', 'late error', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('completed');
expect(e.error).toBeUndefined();
});
});

describe('abortAll', () => {
it('cancels every running entry and leaves terminal entries alone', () => {
const reg = new BackgroundShellRegistry();
const acRunning1 = new AbortController();
const acRunning2 = new AbortController();
const acDone = new AbortController();
reg.register(makeEntry({ shellId: 'a', abortController: acRunning1 }));
reg.register(makeEntry({ shellId: 'b', abortController: acRunning2 }));
reg.register(makeEntry({ shellId: 'c', abortController: acDone }));
reg.complete('c', 0, 1500);

reg.abortAll();

expect(reg.get('a')!.status).toBe('cancelled');
expect(reg.get('b')!.status).toBe('cancelled');
expect(reg.get('c')!.status).toBe('completed');
expect(acRunning1.signal.aborted).toBe(true);
expect(acRunning2.signal.aborted).toBe(true);
expect(acDone.signal.aborted).toBe(false);
});

it('is a no-op when registry is empty', () => {
const reg = new BackgroundShellRegistry();
expect(() => reg.abortAll()).not.toThrow();
});
});

describe('cancel', () => {
it('transitions running → cancelled and aborts the signal', () => {
const reg = new BackgroundShellRegistry();
const ac = new AbortController();
reg.register(makeEntry({ shellId: 'a', abortController: ac }));
reg.cancel('a', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('cancelled');
expect(e.endTime).toBe(2000);
expect(ac.signal.aborted).toBe(true);
});

it('is a no-op when entry is already terminal', () => {
const reg = new BackgroundShellRegistry();
const ac = new AbortController();
reg.register(makeEntry({ shellId: 'a', abortController: ac }));
reg.complete('a', 0, 1500);
reg.cancel('a', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('completed');
expect(ac.signal.aborted).toBe(false);
});

it('is a no-op for unknown id', () => {
const reg = new BackgroundShellRegistry();
expect(() => reg.cancel('missing', 0)).not.toThrow();
});
});
});
Loading
Loading