Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
140 changes: 140 additions & 0 deletions packages/cli/src/ui/commands/tasksCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import type {
BackgroundShellEntry,
BackgroundTaskEntry,
MonitorEntry,
} from '@qwen-code/qwen-code-core';

type AgentTaskTestEntry = BackgroundTaskEntry & {
Expand Down Expand Up @@ -46,19 +47,40 @@ function agentEntry(
};
}

function monitorEntry(overrides: Partial<MonitorEntry> = {}): MonitorEntry {
return {
monitorId: 'mon-aaaaaaaa',
command: 'tail -f app.log',
description: 'watch app logs',
status: 'running',
startTime: Date.now() - 4_000,
abortController: new AbortController(),
eventCount: 0,
lastEventTime: 0,
maxEvents: 1000,
idleTimeoutMs: 300_000,
droppedLines: 0,
...overrides,
};
}

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

beforeEach(() => {
getShells = vi.fn().mockReturnValue([]);
getAgents = vi.fn().mockReturnValue([]);
getMonitors = vi.fn().mockReturnValue([]);
context = createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll: getShells }),
getBackgroundTaskRegistry: () => ({ getAll: getAgents }),
getMonitorRegistry: () => ({ getAll: getMonitors }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
Expand Down Expand Up @@ -157,4 +179,122 @@ describe('tasksCommand', () => {
);
expect(result.content).toContain('[bg_shell] running');
});

Comment thread
wenshao marked this conversation as resolved.
Resolved
it('lists monitor entries with eventCount and exit / error suffixes', async () => {
getMonitors.mockReturnValue([
monitorEntry({
monitorId: 'mon_run',
description: 'tail dev server log',
status: 'running',
eventCount: 12,
pid: 9999,
}),
monitorEntry({
monitorId: 'mon_done',
description: 'tail short script',
status: 'completed',
exitCode: 0,
eventCount: 7,
endTime: Date.now() - 1_000,
}),
monitorEntry({
monitorId: 'mon_auto',
description: 'tail noisy log',
status: 'completed',
error: 'Max events reached',
eventCount: 1000,
endTime: Date.now() - 500,
}),
Comment thread
wenshao marked this conversation as resolved.
Resolved
monitorEntry({
monitorId: 'mon_fail',
description: 'tail bad path',
status: 'failed',
error: 'spawn ENOENT',
eventCount: 0,
endTime: Date.now() - 100,
}),
]);

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

expect(result.content).toContain('Background tasks (4 total)');
// Pluralised eventCount + pid for the running monitor.
expect(result.content).toContain('[mon_run] running (12 events)');
expect(result.content).toContain('pid=9999');
expect(result.content).toContain('tail dev server log');
// Natural completion path uses exitCode + eventCount.
expect(result.content).toContain('[mon_done] completed (exit 0, 7 events)');
// Auto-stop path uses the error string instead of exit code (so users
// see WHY the monitor stopped, not just that it did).
expect(result.content).toContain(
'[mon_auto] completed (Max events reached, 1000 events)',
);
expect(result.content).toContain('[mon_fail] failed: spawn ENOENT');
Comment thread
wenshao marked this conversation as resolved.
Resolved
// No on-disk output file for monitors — events stream via
// task_notification, so the "output:" line should not appear for
// any monitor entry.
expect(result.content).not.toContain('output: ');
});

it('singular form: "1 event" not "1 events"', async () => {
getMonitors.mockReturnValue([
monitorEntry({ monitorId: 'mon_one', eventCount: 1 }),
]);
const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('running (1 event)');
// Guard against "1 event" matching the prefix of "1 events" by accident.
expect(result.content).not.toContain('1 events');
});

it('shows the Ctrl+T hint only in interactive mode', async () => {
getShells.mockReturnValue([entry({ shellId: 'bg_x' })]);

// non_interactive (default in beforeEach) — no hint.
const noHint = await tasksCommand.action!(context, '');
if (!noHint || noHint.type !== 'message') throw new Error('no result');
expect(noHint.content).not.toContain('Ctrl+T');

// Re-bind the same config under an interactive context.
const interactiveCtx = createMockCommandContext({
executionMode: 'interactive',
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll: getShells }),
getBackgroundTaskRegistry: () => ({ getAll: getAgents }),
getMonitorRegistry: () => ({ getAll: getMonitors }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
const withHint = await tasksCommand.action!(interactiveCtx, '');
if (!withHint || withHint.type !== 'message') throw new Error('no result');
expect(withHint.content).toContain('Ctrl+T');
expect(withHint.content).toContain('Background tasks (1 total)');
});

it('merges all three kinds and orders them by startTime', async () => {
const now = Date.now();
getAgents.mockReturnValue([
agentEntry({ agentId: 'a_late', startTime: now - 1_000 }),
]);
getShells.mockReturnValue([
entry({ shellId: 'bg_early', startTime: now - 10_000 }),
]);
getMonitors.mockReturnValue([
monitorEntry({ monitorId: 'mon_mid', startTime: now - 5_000 }),
]);

const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
const order = ['bg_early', 'mon_mid', 'a_late'].map((id) =>
result.content.indexOf(`[${id}]`),
);
// All present and strictly increasing — proves startTime sort.
expect(order.every((i) => i >= 0)).toBe(true);
expect(order[0]).toBeLessThan(order[1]);
expect(order[1]).toBeLessThan(order[2]);
});
});
90 changes: 77 additions & 13 deletions packages/cli/src/ui/commands/tasksCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildBackgroundEntryLabel,
type BackgroundShellEntry,
type BackgroundTaskEntry,
type MonitorEntry,
} from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
Expand All @@ -20,8 +21,9 @@ type AgentTaskEntry = BackgroundTaskEntry & {
};

type ShellTaskEntry = BackgroundShellEntry & { kind: 'shell' };
type MonitorTaskEntry = MonitorEntry & { kind: 'monitor' };

type TaskEntry = AgentTaskEntry | ShellTaskEntry;
type TaskEntry = AgentTaskEntry | ShellTaskEntry | MonitorTaskEntry;

function statusLabel(entry: TaskEntry): string {
if (entry.kind === 'agent') {
Expand All @@ -42,41 +44,87 @@ function statusLabel(entry: TaskEntry): string {
}
Comment thread
wenshao marked this conversation as resolved.
Resolved
}

if (entry.kind === 'shell') {
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 'running';
}
}

// monitor — append eventCount as a glanceable signal for activity. error
// (set on `failed` and on auto-stopped `completed`) is included verbatim.
const events = `${entry.eventCount} event${entry.eventCount === 1 ? '' : 's'}`;
switch (entry.status) {
case 'completed':
return `completed (exit ${entry.exitCode ?? '?'})`;
return entry.error
Comment thread
wenshao marked this conversation as resolved.
Resolved
? `completed (${entry.error}, ${events})`
: `completed (exit ${entry.exitCode ?? '?'}, ${events})`;
case 'failed':
return `failed: ${entry.error ?? 'unknown error'}`;
return `failed: ${entry.error ?? 'unknown error'} (${events})`;
Comment thread
wenshao marked this conversation as resolved.
Resolved
case 'cancelled':
return 'cancelled';
return `cancelled (${events})`;
case 'running':
return 'running';
return `running (${events})`;
default:
return 'running';
return `running (${events})`;
}
}

function taskLabel(entry: TaskEntry): string {
if (entry.kind === 'agent') {
return buildBackgroundEntryLabel(entry);
}
return entry.command;
if (entry.kind === 'shell') {
return entry.command;
}
return entry.description;
}
Comment thread
wenshao marked this conversation as resolved.
Resolved

function taskId(entry: TaskEntry): string {
return entry.kind === 'agent' ? entry.agentId : entry.shellId;
switch (entry.kind) {
case 'agent':
return entry.agentId;
case 'shell':
return entry.shellId;
case 'monitor':
return entry.monitorId;
default: {
const _exhaustive: never = entry;
throw new Error(
`taskId: unknown TaskEntry kind: ${JSON.stringify(_exhaustive)}`,
);
}
}
}

function taskOutputPath(entry: TaskEntry): string | undefined {
Comment thread
wenshao marked this conversation as resolved.
Resolved
return entry.kind === 'agent' ? entry.outputFile : entry.outputPath;
if (entry.kind === 'agent') return entry.outputFile;
if (entry.kind === 'shell') return entry.outputPath;
// Monitors stream to the agent via task_notification rather than a
// file on disk — no output path to surface here.
return undefined;
}

export const tasksCommand: SlashCommand = {
name: 'tasks',
get description() {
return t('List background tasks');
return t('List background tasks (text dump — interactive UI is Ctrl+T)');
},
kind: CommandKind.BUILT_IN,
// Kept on all three modes: the interactive dialog (Ctrl+T) is the
// richer surface when a TTY is available, but `non_interactive` and
// `acp` consumers (headless `-p`, IDE bridges, SDK) have no dialog
// and rely on this text dump as the only way to inspect background
// task state. See the interactive-mode hint at the top of the output
// for the soft redirect.
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context) => {
const { config } = context.services;
Expand All @@ -96,7 +144,11 @@ export const tasksCommand: SlashCommand = {
.getBackgroundShellRegistry()
.getAll()
.map((entry) => ({ ...entry, kind: 'shell' as const }));
const entries = [...agentEntries, ...shellEntries].sort(
const monitorEntries: MonitorTaskEntry[] = config
.getMonitorRegistry()
Comment thread
wenshao marked this conversation as resolved.
Resolved
.getAll()
.map((entry) => ({ ...entry, kind: 'monitor' as const }));
const entries = [...agentEntries, ...shellEntries, ...monitorEntries].sort(
(a, b) => a.startTime - b.startTime,
);

Expand All @@ -109,14 +161,26 @@ export const tasksCommand: SlashCommand = {
}

const now = Date.now();
const lines: string[] = [`Background tasks (${entries.length} total)`, ''];
const lines: string[] = [];
// Soft redirect: in interactive mode the dialog (Ctrl+T) is richer
// (per-entry detail view, live updates, cancel keybinding). Don't
// show the hint in non_interactive / acp — those consumers have no
// dialog to point at and the noise just clutters their output.
Comment thread
wenshao marked this conversation as resolved.
Resolved
if (context.executionMode === 'interactive') {
lines.push(
'Tip: Ctrl+T opens the interactive Background tasks dialog with detail view + live updates.',
Comment thread
wenshao marked this conversation as resolved.
Resolved
'',
);
}
lines.push(`Background tasks (${entries.length} total)`, '');
for (const entry of entries) {
const endTime = entry.endTime ?? now;
const runtime = formatDuration(endTime - entry.startTime, {
hideTrailingZeros: true,
});
const pidPart =
entry.kind === 'shell' && entry.pid !== undefined
(entry.kind === 'shell' || entry.kind === 'monitor') &&
entry.pid !== undefined
? ` pid=${entry.pid}`
: '';
lines.push(
Expand Down
Loading