Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions packages/cli/src/services/BuiltinCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe('BuiltinCommandLoader', () => {
getDisableAllHooks: vi.fn().mockReturnValue(false),
getManagedAutoMemoryEnabled: vi.fn().mockReturnValue(true),
isLspEnabled: vi.fn().mockReturnValue(false),
isWorkflowsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;

restoreCommandMock.mockReturnValue({
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { workflowsCommand } from '../ui/commands/workflowsCommand.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 @@ -100,6 +101,12 @@ export class BuiltinCommandLoader implements ICommandLoader {
aboutCommand,
agentsCommand,
tasksCommand,
// Gated behind isWorkflowsEnabled — feature flag honors
// QWEN_CODE_ENABLE_WORKFLOWS (opt-in) and QWEN_CODE_DISABLE_WORKFLOWS
// (kill switch). When the flag is off the command vanishes entirely
// from typeahead and help, matching the established convention for
// experimental builtins.
this.config?.isWorkflowsEnabled() ? workflowsCommand : null,
arenaCommand,
approvalModeCommand,
authCommand,
Expand Down
160 changes: 160 additions & 0 deletions packages/cli/src/ui/commands/workflowsCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach } from 'vitest';
import { workflowsCommand } from './workflowsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { WorkflowTask } from '@qwen-code/qwen-code-core';

function entry(overrides: Partial<WorkflowTask> = {}): WorkflowTask {
return {
id: 'wf_aaaaaaaa',
kind: 'workflow',
runId: 'wf_aaaaaaaa',
description: 'demo',
meta: null,
status: 'running',
startTime: 1_700_000_000_000,
outputFile: '',
outputOffset: 0,
notified: false,
abortController: new AbortController(),
currentPhase: null,
phases: [],
agentsDispatched: 0,
agentsCompleted: 0,
recentLogs: [],
...overrides,
};
}

describe('workflowsCommand', () => {
let context: CommandContext;
let listMock: ReturnType<typeof vi.fn>;
let getMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
listMock = vi.fn().mockReturnValue([] as WorkflowTask[]);
getMock = vi.fn().mockReturnValue(undefined);
context = createMockCommandContext({
services: {
config: {
getWorkflowRunRegistry: () => ({
list: listMock,
get: getMock,
}),
},
},
executionMode: 'interactive',
} as unknown as Parameters<typeof createMockCommandContext>[0]);
});

it('returns info message when there are no runs', async () => {
const result = await workflowsCommand.action!(context, '');
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
content: 'No workflow runs recorded yet.',
});
});

it('lists active + recent buckets with running first', async () => {
listMock.mockReturnValue([
entry({
runId: 'wf_done',
status: 'completed',
endTime: 1_700_000_010_000,
}),
entry({
runId: 'wf_running',
meta: { name: 'capitals', description: 'd' },
status: 'running',
currentPhase: 'Plan',
phases: ['Plan'],
agentsDispatched: 2,
agentsCompleted: 1,
}),
]);
const result = await workflowsCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Workflow runs (2 total · 1 running)');
const activeIdx = result.content.indexOf('Active');
const recentIdx = result.content.indexOf('Recent');
// Active section comes before Recent in the output.
expect(activeIdx).toBeGreaterThan(-1);
expect(recentIdx).toBeGreaterThan(activeIdx);
expect(result.content).toContain('wf_running');
expect(result.content).toContain('Plan');
expect(result.content).toContain('1/2 agents');
expect(result.content).toContain('wf_done');
expect(result.content).toContain('capitals');
});

it('omits the interactive tip in non_interactive / acp modes', async () => {
const ctx = createMockCommandContext({
services: {
config: {
getWorkflowRunRegistry: () => ({ list: listMock, get: getMock }),
},
},
executionMode: 'non_interactive',
} as unknown as Parameters<typeof createMockCommandContext>[0]);
listMock.mockReturnValue([entry()]);
const result = await workflowsCommand.action!(ctx, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).not.toMatch(/Tip:/);
});

it('detail view: known runId returns full per-field dump', async () => {
const detail = entry({
runId: 'wf_target',
meta: {
name: 'demo',
description: 'd',
whenToUse: 'when stuff',
},
status: 'completed',
phases: ['A', 'B'],
agentsDispatched: 3,
agentsCompleted: 3,
recentLogs: ['log1', 'log2'],
endTime: 1_700_000_010_000,
});
getMock.mockImplementation((id) =>
id === 'wf_target' ? detail : undefined,
);
const result = await workflowsCommand.action!(context, 'wf_target');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.messageType).toBe('info');
expect(result.content).toContain('Workflow wf_target');
expect(result.content).toContain('name : demo');
expect(result.content).toContain('whenToUse : when stuff');
expect(result.content).toContain('agents : 3/3');
expect(result.content).toContain('· A');
expect(result.content).toContain('· B');
expect(result.content).toContain('log1');
expect(result.content).toContain('log2');
});

it('detail view: unknown runId returns clear error', async () => {
getMock.mockReturnValue(undefined);
const result = await workflowsCommand.action!(context, 'wf_missing');
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: 'Unknown workflow runId: wf_missing',
});
});

it('argument is trimmed before lookup', async () => {
const target = entry({ runId: 'wf_t' });
getMock.mockImplementation((id) => (id === 'wf_t' ? target : undefined));
const result = await workflowsCommand.action!(context, ' wf_t ');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Workflow wf_t');
});
});
179 changes: 179 additions & 0 deletions packages/cli/src/ui/commands/workflowsCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import type { WorkflowTask } 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';
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved

/**
* Format one workflow run as a one-line summary used by both the
* top-level listing and the per-run detail view.
*/
function rowLine(entry: WorkflowTask, now: number): string {
const endTime = entry.endTime ?? now;
const runtime = formatDuration(endTime - entry.startTime, {
hideTrailingZeros: true,
});
const label = entry.meta?.name ?? entry.runId;
const phase = entry.currentPhase ? ` · ${entry.currentPhase}` : '';
const counts =
entry.agentsDispatched > 0
? ` · ${entry.agentsCompleted}/${entry.agentsDispatched} agents`
: '';
const phaseCount =
entry.phases.length > 0
? ` · ${entry.phases.length} ${entry.phases.length === 1 ? 'phase' : 'phases'}`
: '';
const errorTail =
entry.status === 'failed' && entry.error
? ` — ${entry.error.slice(0, 80)}`
: '';
return ` ${entry.runId.padEnd(20)} ${entry.status.padEnd(10)} ${runtime.padStart(8)} ${label}${phase}${counts}${phaseCount}${errorTail}`;
}

function detailLines(entry: WorkflowTask, now: number): string[] {
const lines: string[] = [];
const endTime = entry.endTime ?? now;
const runtime = formatDuration(endTime - entry.startTime, {
hideTrailingZeros: true,
});

lines.push(`Workflow ${entry.runId}`);
if (entry.meta?.name) {
lines.push(` name : ${entry.meta.name}`);
}
if (entry.meta?.description) {
lines.push(` description : ${entry.meta.description}`);
}
if (entry.meta?.whenToUse) {
lines.push(` whenToUse : ${entry.meta.whenToUse}`);
}
lines.push(` status : ${entry.status}`);
lines.push(` runtime : ${runtime}`);
if (entry.currentPhase) {
lines.push(` currentPhase: ${entry.currentPhase}`);
}
lines.push(
` agents : ${entry.agentsCompleted}/${entry.agentsDispatched}`,
);
if (entry.error) {
lines.push(` error : ${entry.error}`);
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved
}
if (entry.phases.length > 0) {
lines.push('');
lines.push(` Phases (${entry.phases.length})`);
for (const phase of entry.phases) {
lines.push(` · ${phase}`);
}
}
if (entry.recentLogs.length > 0) {
lines.push('');
lines.push(` Logs (last ${entry.recentLogs.length})`);
for (const line of entry.recentLogs) {
lines.push(` ${line}`);
}
}
return lines;
}

export const workflowsCommand: SlashCommand = {
name: 'workflows',
get description() {
return t(
'List active and completed workflow runs (text dump — interactive dialog opens via the footer pill)',
);
},
get argumentHint() {
return t('[runId]');
},
kind: CommandKind.BUILT_IN,
// Same triple-mode coverage as `/tasks`: the dialog is richer in
// interactive mode but headless / acp consumers need the text dump
// as their only inspection path.
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context, args) => {
const { config } = context.services;
if (!config) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: 'Config not available.',
};
}
const registry = config.getWorkflowRunRegistry();
const allEntries = registry.list();

// Targeted detail view: `/workflows wf_abc123` opens the detail
// dump for that run if it exists. Reject early on unknown runId so
// the user sees a clear error instead of an empty listing.
const trimmedArgs = (args ?? '').trim();
if (trimmedArgs.length > 0) {
const target = registry.get(trimmedArgs);
if (!target) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: `Unknown workflow runId: ${trimmedArgs}`,
};
}
return {
type: 'message' as const,
messageType: 'info' as const,
content: detailLines(target, Date.now()).join('\n'),
};
}

if (allEntries.length === 0) {
return {
type: 'message' as const,
messageType: 'info' as const,
content: 'No workflow runs recorded yet.',
};
}

const now = Date.now();
// Order: running first (oldest startTime first inside the bucket so
// long-runners stay visible), then terminal by endTime DESC. Mirrors
// the dialog's two-bucket sort.
const running = allEntries
.filter((e) => e.status === 'running')
.sort((a, b) => a.startTime - b.startTime);
const terminal = allEntries
.filter((e) => e.status !== 'running')
.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0));

const lines: string[] = [];
if (context.executionMode === 'interactive') {
lines.push(
t(
'Tip: use `/workflows <runId>` for the per-run detail view (name, description, phase tree, recent logs).',
),
'',
);
}
lines.push(
`Workflow runs (${allEntries.length} total · ${running.length} running)`,
'',
);
if (running.length > 0) {
lines.push('Active');
for (const entry of running) lines.push(rowLine(entry, now));
lines.push('');
}
if (terminal.length > 0) {
lines.push('Recent');
for (const entry of terminal) lines.push(rowLine(entry, now));
}

return {
type: 'message' as const,
messageType: 'info' as const,
content: lines.join('\n'),
};
},
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ vi.mock('../../hooks/useBackgroundTaskView.js', () => ({
return entry.shellId;
case 'monitor':
return entry.monitorId;
case 'workflow':
return entry.runId;
case 'dream':
return entry.dreamId;
default: {
Expand Down
Loading
Loading