Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9eb2594
feat(core): workflow() nested global — single-level saved-workflow in…
LaZzyMan Jun 22, 2026
dfd8180
feat(core): workflow agent stall watchdog + retry (#4721 P-stall)
LaZzyMan Jun 22, 2026
0d9192b
fix(core): distinguish budget-exhausted slot drops in parallel/pipeli…
LaZzyMan Jun 22, 2026
8343f16
feat(core): same-session workflow resume via JSONL journal (#4721 P6)
LaZzyMan Jun 22, 2026
1b3d7ac
fix(core): drop unused @ts-expect-error in workflow-journal test (#4721)
LaZzyMan Jun 22, 2026
30e5a23
feat(core): persist workflow run snapshots for cross-session /workflo…
LaZzyMan Jun 22, 2026
997ceeb
feat(cli): saved workflows as /<name> slash commands (#4721 P7b-A1)
LaZzyMan Jun 22, 2026
53af571
feat(cli): save a completed run as a reusable workflow from /workflow…
LaZzyMan Jun 22, 2026
3886b07
feat(cli): workflow keyword trigger + footer indicator (#4721 P7-trig…
LaZzyMan Jun 22, 2026
7b0bb8f
feat: terminal-bell notification on workflow completion (#4721 P-notif)
LaZzyMan Jun 22, 2026
ff13caa
feat: workflow telemetry events (#4721 P-telemetry)
LaZzyMan Jun 22, 2026
18d5aad
test(core): direct unit coverage for the workflow snapshot module (#4…
LaZzyMan Jun 22, 2026
f882c77
fix(core): use Array<T> for JournalEntry annotations in orchestrator …
LaZzyMan Jun 22, 2026
56b8743
fix(core): correct stale WorkflowTool description + cover workflow-co…
LaZzyMan Jun 22, 2026
29367ed
chore(cli): regenerate vscode settings schema for disableWorkflowKeyw…
LaZzyMan Jun 22, 2026
5a1fb6a
fix(cli): let a workflow-only session open the background-tasks dialo…
LaZzyMan Jun 22, 2026
564593a
fix(workflows): address review round 1 — path-traversal, symlink, hea…
LaZzyMan Jun 22, 2026
dd587f9
fix(cli): let ↓ descend from the Arena tab bar into the bg-tasks pill…
LaZzyMan Jun 22, 2026
c343bcb
fix(workflows): refuse a symlinked saved-workflow root dir (#4721)
LaZzyMan Jun 22, 2026
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
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,16 @@ const SETTINGS_SCHEMA = {
description: 'Hide the window title bar',
showInDialog: false,
},
disableWorkflowKeywordTrigger: {
type: 'boolean',
label: 'Disable Workflow Keyword Trigger',
category: 'UI',
requiresRestart: false,
default: false,
description:
'When true, mentioning the word `workflow` in a prompt no longer softly steers the turn toward the Workflow tool (and the Footer `workflow active` indicator is suppressed). Only applies when workflows are enabled.',
showInDialog: true,
},
showStatusInTitle: {
type: 'boolean',
label: 'Show Status in Title',
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/nonInteractiveCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from './services/BundledSkillLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import { SavedWorkflowLoader } from './services/saved-workflow-loader.js';
import { McpPromptLoader } from './services/McpPromptLoader.js';
import { SkillCommandLoader } from './services/SkillCommandLoader.js';
import {
Expand Down Expand Up @@ -337,6 +338,7 @@ export const handleSlashCommand = async (
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new SkillCommandLoader(config),
new SavedWorkflowLoader(config),
new FileCommandLoader(config),
];

Expand Down Expand Up @@ -515,6 +517,7 @@ export const getAvailableCommands = async (
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new SkillCommandLoader(config),
new SavedWorkflowLoader(config),
new FileCommandLoader(config),
];

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/services/commandMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ describe('formatCommandSourceLabel', () => {
expect(formatCommandSourceLabel(cmd)).toBe('MCP');
});

it('returns Workflow for workflow-command', () => {
const cmd = makeCmd({ source: 'workflow-command', sourceLabel: undefined });
expect(formatCommandSourceLabel(cmd)).toBe('Workflow');
});

it('returns Unknown when source is falsy', () => {
const cmd = makeCmd({
source: undefined as unknown as SlashCommand['source'],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/services/commandMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export function formatCommandSourceLabel(
'skill-dir-command': 'Custom',
'plugin-command': 'Plugin',
'mcp-prompt': 'MCP',
'workflow-command': 'Workflow',
};

return command.source ? fallbackLabels[command.source] : 'Unknown';
Expand Down
180 changes: 180 additions & 0 deletions packages/cli/src/services/saved-workflow-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach } from 'vitest';

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
listSavedWorkflows: vi.fn(),
};
});

import {
listSavedWorkflows,
type Config,
type SavedWorkflowEntry,
} from '@qwen-code/qwen-code-core';
import { SavedWorkflowLoader } from './saved-workflow-loader.js';
import { CommandKind, type CommandContext } from '../ui/commands/types.js';

const listMock = vi.mocked(listSavedWorkflows);

function makeConfig(overrides: Partial<Record<string, unknown>> = {}): Config {
return {
isWorkflowsEnabled: () => true,
getBareMode: () => false,
getFolderTrustFeature: () => false,
getFolderTrust: () => false,
...overrides,
} as unknown as Config;
}

const ctx = {} as CommandContext;
const signal = new AbortController().signal;

function entry(overrides: Partial<SavedWorkflowEntry> = {}): SavedWorkflowEntry {
return {
name: 'deep-research',
scriptPath: '/proj/.qwen/workflows/deep-research.js',
source: 'project',
...overrides,
};
}

describe('SavedWorkflowLoader', () => {
beforeEach(() => {
listMock.mockReset();
listMock.mockResolvedValue([]);
});

it('builds one tool-dispatch command per discovered workflow', async () => {
listMock.mockResolvedValue([
entry({ name: 'deep-research', source: 'project' }),
entry({
name: 'triage',
scriptPath: '/home/.qwen/workflows/triage.js',
source: 'user',
}),
]);
const cmds = await new SavedWorkflowLoader(makeConfig()).loadCommands(
signal,
);
expect(cmds.map((c) => c.name)).toEqual(['deep-research', 'triage']);
const c = cmds[0];
expect(c.kind).toBe(CommandKind.FILE);
expect(c.source).toBe('workflow-command');
expect(c.sourceDetail).toBe('project');
// Interactive only — the tool-dispatch action can't run in headless / ACP,
// so advertising those modes would surface a command that then fails.
expect(c.supportedModes).toEqual(['interactive']);
});

it('action dispatches the workflow tool with the scriptPath', async () => {
listMock.mockResolvedValue([entry()]);
const [cmd] = await new SavedWorkflowLoader(makeConfig()).loadCommands(
signal,
);
const result = await cmd.action!(ctx, '');
expect(result).toEqual({
type: 'tool',
toolName: 'workflow',
toolArgs: { scriptPath: '/proj/.qwen/workflows/deep-research.js' },
});
});

it('parses JSON args and forwards them as the `args` global', async () => {
listMock.mockResolvedValue([entry()]);
const [cmd] = await new SavedWorkflowLoader(makeConfig()).loadCommands(
signal,
);
const result = await cmd.action!(ctx, '{"topic":"llms","depth":3}');
expect(result).toMatchObject({
type: 'tool',
toolName: 'workflow',
toolArgs: {
scriptPath: '/proj/.qwen/workflows/deep-research.js',
args: { topic: 'llms', depth: 3 },
},
});
});

it('forwards non-JSON args as a raw string', async () => {
listMock.mockResolvedValue([entry()]);
const [cmd] = await new SavedWorkflowLoader(makeConfig()).loadCommands(
signal,
);
const result = await cmd.action!(ctx, 'just some text');
expect(result).toMatchObject({
toolArgs: { args: 'just some text' },
});
});

it('omits `args` when no input is supplied', async () => {
listMock.mockResolvedValue([entry()]);
const [cmd] = await new SavedWorkflowLoader(makeConfig()).loadCommands(
signal,
);
const result = (await cmd.action!(ctx, ' ')) as {
toolArgs: Record<string, unknown>;
};
expect('args' in result.toolArgs).toBe(false);
});

it('returns [] when workflows are disabled (tool not registered)', async () => {
listMock.mockResolvedValue([entry()]);
const cmds = await new SavedWorkflowLoader(
makeConfig({ isWorkflowsEnabled: () => false }),
).loadCommands(signal);
expect(cmds).toEqual([]);
expect(listMock).not.toHaveBeenCalled();
});

it('returns [] in bare mode', async () => {
listMock.mockResolvedValue([entry()]);
const cmds = await new SavedWorkflowLoader(
makeConfig({ getBareMode: () => true }),
).loadCommands(signal);
expect(cmds).toEqual([]);
expect(listMock).not.toHaveBeenCalled();
});

it('returns [] when folder trust is enabled but the folder is untrusted', async () => {
listMock.mockResolvedValue([entry()]);
const cmds = await new SavedWorkflowLoader(
makeConfig({
getFolderTrustFeature: () => true,
getFolderTrust: () => false,
}),
).loadCommands(signal);
expect(cmds).toEqual([]);
expect(listMock).not.toHaveBeenCalled();
});

it('returns [] for a null config', async () => {
const cmds = await new SavedWorkflowLoader(null).loadCommands(signal);
expect(cmds).toEqual([]);
});

it('swallows enumeration errors and returns []', async () => {
listMock.mockRejectedValue(new Error('readdir blew up'));
const cmds = await new SavedWorkflowLoader(makeConfig()).loadCommands(
signal,
);
expect(cmds).toEqual([]);
});

it('returns [] when the signal aborts during enumeration', async () => {
listMock.mockResolvedValue([entry()]);
const aborted = AbortSignal.abort();
const cmds = await new SavedWorkflowLoader(makeConfig()).loadCommands(
aborted,
);
expect(cmds).toEqual([]);
});
});
112 changes: 112 additions & 0 deletions packages/cli/src/services/saved-workflow-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @fileoverview Discovers saved workflow scripts under `.qwen/workflows/`
* (project) and `~/.qwen/workflows/` (user) and exposes each as a `/<name>`
* slash command that dispatches the `workflow` tool with the file's path.
* The script is read at execution time (by the tool), so edits to a saved
* workflow take effect on the next invocation.
*
* Enumeration, project-over-user precedence, and the name constraint all live
* in core's `listSavedWorkflows` — the single source of truth shared with the
* `workflow('<name>')` in-script global. This loader only adapts the
* discovered entries into `SlashCommand` objects.
*/

import type { Config, SavedWorkflowEntry } from '@qwen-code/qwen-code-core';
import {
listSavedWorkflows,
ToolNames,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import type { ICommandLoader } from './types.js';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
import { CommandKind } from '../ui/commands/types.js';

const debugLogger = createDebugLogger('SavedWorkflowLoader');

export class SavedWorkflowLoader implements ICommandLoader {
constructor(private readonly config: Config | null) {}

async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
if (!this.config) return [];
// Feature gate: the `workflow` tool is only registered when the feature
// flag is on, so without this guard the commands would dispatch a tool
// that doesn't exist.
if (!this.config.isWorkflowsEnabled?.()) return [];
// Mirror FileCommandLoader: saved workflows execute project-local code, so
// skip discovery in bare mode and in untrusted folders.
if (this.config.getBareMode?.()) return [];
const folderTrustEnabled = !!this.config.getFolderTrustFeature?.();
const folderTrust = !!this.config.getFolderTrust?.();
if (folderTrustEnabled && !folderTrust) return [];

let entries: SavedWorkflowEntry[];
try {
entries = await listSavedWorkflows(this.config);
} catch (e) {
debugLogger.debug(`listSavedWorkflows failed: ${e}`);
return [];
}
if (signal.aborted) return [];
return entries.map((entry) => this.toCommand(entry));
}

private toCommand(entry: SavedWorkflowEntry): SlashCommand {
return {
name: entry.name,
description: `Run the "${entry.name}" saved workflow (${entry.source})`,
// File-derived command (all execution modes via commandUtils fallback);
// `source` carries the distinct workflow identity for display/telemetry.
kind: CommandKind.FILE,
source: 'workflow-command',
sourceLabel: 'Workflow',
sourceDetail: entry.source, // 'project' | 'user'
// Interactive only: the action returns a `{type:'tool'}` dispatch, which
// the non-interactive command adapter converts to `unsupported`. Listing
// these in headless / ACP modes would advertise a command that then fails
// to run, so restrict them until those paths can execute a tool return.
supportedModes: ['interactive'],
acceptsInput: true,
argumentHint: '[json-args]',
action: (
_context: CommandContext,
args: string,
): SlashCommandActionReturn => {
const toolArgs: Record<string, unknown> = {
// The tool reads the file fresh at execution time (hot reload).
scriptPath: entry.scriptPath,
};
const trimmed = (args ?? '').trim();
if (trimmed.length > 0) {
// Forward user-supplied text to the script's `args` global. Parse as
// JSON when valid (objects / arrays / numbers — matching the tool's
// "actual JSON value" contract), else pass the raw string so plain
// text still reaches the script.
toolArgs['args'] = tryParseJson(trimmed);
}
return {
type: 'tool',
toolName: ToolNames.WORKFLOW,
toolArgs,
};
},
};
}
}

function tryParseJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
Loading
Loading