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
1 change: 1 addition & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | |
| `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | |
| `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | |
| `tools.workflowsEnabled` | boolean | Enable the Workflow tool, which lets the model author and run a script that orchestrates subagents in parallel. Off by default; a run can dispatch many subagents and spend tokens accordingly. | `false` | User, System, and SystemDefaults scopes only; workspace values are ignored. Requires restart: Yes. Env overrides: `QWEN_CODE_ENABLE_WORKFLOWS=1` forces on; `QWEN_CODE_DISABLE_WORKFLOWS=1` forces off (disable wins). |
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
| `tools.computerUse.enabled` | boolean | Enable the built-in Computer Use tools (cua-driver native desktop automation). When `true` (default), the `computer_use__*` tools are registered as deferred built-ins; the first invocation downloads the pinned, signed cua-driver binary into `~/.qwen/computer-use/` and walks through macOS Accessibility / Screen Recording permissions. | `true` | Requires restart: Yes |
Expand Down
2 changes: 1 addition & 1 deletion docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Commands for managing AI tools and models.

> [!note]
>
> `/workflows`, `/lsp`, and `/trust` are registered only when their feature is enabled — via the `QWEN_CODE_ENABLE_WORKFLOWS=1` env var, the `--experimental-lsp` CLI flag, and the `security.folderTrust.enabled` setting respectively. When disabled they won't appear and will report an unknown command. Similarly, `/dream` and `/forget` are registered only when managed auto-memory is available; without it they won't appear.
> `/workflows`, `/lsp`, and `/trust` are registered only when their feature is enabled — via the user/system-scoped `tools.workflowsEnabled` setting or `QWEN_CODE_ENABLE_WORKFLOWS=1` env var, the `--experimental-lsp` CLI flag, and the `security.folderTrust.enabled` setting respectively. Workspace values for `tools.workflowsEnabled` are ignored. When disabled these commands won't appear and will report an unknown command. Similarly, `/dream` and `/forget` are registered only when managed auto-memory is available; without it they won't appear.

### 1.5 Built-in Skills

Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3925,6 +3925,62 @@ describe('loadCliConfig useBuiltinRipgrep', () => {
});
});

describe('loadCliConfig workflowsEnabled', () => {
const originalArgv = process.argv;

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.stubEnv('QWEN_CODE_ENABLE_WORKFLOWS', undefined);
vi.stubEnv('QWEN_CODE_DISABLE_WORKFLOWS', undefined);
});

afterEach(() => {
process.argv = originalArgv;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('should be disabled by default when workflowsEnabled is not set in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.isWorkflowsEnabled()).toBe(false);
});

// The regression this whole setting exists to prevent: `workflowsEnabled`
// was declared on ConfigParameters and read by isWorkflowsEnabled(), but
// loadCliConfig never wrote it — so the setting was a dead switch and the
// env var was the only way in.
it('should be enabled when workflowsEnabled is set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { tools: { workflowsEnabled: true } };
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.isWorkflowsEnabled()).toBe(true);
});

it('should let the QWEN_CODE_DISABLE_WORKFLOWS kill switch override a true setting', async () => {
vi.stubEnv('QWEN_CODE_DISABLE_WORKFLOWS', '1');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { tools: { workflowsEnabled: true } };
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.isWorkflowsEnabled()).toBe(false);
});

it('should let QWEN_CODE_ENABLE_WORKFLOWS enable workflows when the setting is false', async () => {
vi.stubEnv('QWEN_CODE_ENABLE_WORKFLOWS', '1');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { tools: { workflowsEnabled: false } };
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.isWorkflowsEnabled()).toBe(true);
});
});

describe('screenReader configuration', () => {
const originalArgv = process.argv;

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2299,6 +2299,7 @@ export async function loadCliConfig(
trustedFolder,
useRipgrep: settings.tools?.useRipgrep,
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
workflowsEnabled: settings.tools?.workflowsEnabled,
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
shellDefaultTimeoutMs: settings.tools?.shell?.defaultTimeoutMs,
shellHeartbeatIntervalMs: settings.tools?.shell?.heartbeatIntervalMs,
Expand Down
160 changes: 160 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ import {
ENV_CORRUPTED_PATH,
ENV_WAS_RECOVERED,
} from './settings.js';
import {
WORKSPACE_RESTRICTED_SETTINGS,
WORKSPACE_RESTRICTED_SETTING_KEYS,
} from '../utils/settingsUtils.js';
import { needsMigration } from './migration/index.js';
import { QWEN_DIR } from '@qwen-code/qwen-code-core';

Expand Down Expand Up @@ -3333,6 +3337,162 @@ describe('Settings Loading and Merging', () => {
});
});

describe('workflowsEnabled scope handling', () => {
it.each([
['system defaults', getSystemDefaultsPath()],
['system', getSystemSettingsPath()],
])('should honor %s scope settings', (_scope, settingsPath) => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === settingsPath)
return JSON.stringify({ tools: { workflowsEnabled: true } });
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.workflowsEnabled).toBe(true);
});

it('should ignore workspace scope while preserving the user value', () => {
Comment thread
qqqys marked this conversation as resolved.
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({ tools: { workflowsEnabled: false } });
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({
tools: { workflowsEnabled: true, useRipgrep: false },
});
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.workflowsEnabled).toBe(false);
expect(settings.merged.tools?.useRipgrep).toBe(false);
});

it('should ignore an explicit workspace false and preserve a user opt-in', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({ tools: { workflowsEnabled: true } });
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({ tools: { workflowsEnabled: false } });
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.workflowsEnabled).toBe(true);
expect(
getSettingsWarnings(settings).some((warning) =>
warning.includes('tools.workflowsEnabled'),
),
).toBe(true);
});

it('should ignore workspace env overrides for workflow enablement', () => {
delete process.env['QWEN_CODE_ENABLE_WORKFLOWS'];
delete process.env['QWEN_CODE_DISABLE_WORKFLOWS'];
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({ tools: { workflowsEnabled: true } });
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({
env: {
QWEN_CODE_ENABLE_WORKFLOWS: '1',
QWEN_CODE_DISABLE_WORKFLOWS: '1',
},
});
return '{}';
},
);

try {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.workflowsEnabled).toBe(true);
expect(process.env['QWEN_CODE_ENABLE_WORKFLOWS']).toBeUndefined();
expect(process.env['QWEN_CODE_DISABLE_WORKFLOWS']).toBeUndefined();
} finally {
delete process.env['QWEN_CODE_ENABLE_WORKFLOWS'];
delete process.env['QWEN_CODE_DISABLE_WORKFLOWS'];
}
});

it('should warn when workspace settings define workflowsEnabled', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({ tools: { workflowsEnabled: true } });
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.workflowsEnabled).toBeUndefined();
expect(
getSettingsWarnings(settings).some((warning) =>
warning.includes('tools.workflowsEnabled'),
),
).toBe(true);
});
});

describe('WORKSPACE_RESTRICTED_SETTINGS as the single source', () => {
// R4-3: the strip, the warning and the dialog filter all derive from this
// list. A key present here but unstripped would be honored from a repo's
// settings while the warning claimed it was ignored — the exact drift the
// hand-maintained trio allowed.
it('strips and warns for every listed key, driven by the list itself', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const workspacePayload: Record<string, Record<string, unknown>> = {};
for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) {
workspacePayload[section] ??= {};
workspacePayload[section][key] =
key === 'allowedInsecureVoiceBaseUrls'
? ['http://voice.example/v1']
: true;
}
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspacePayload);
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
const warnings = getSettingsWarnings(settings);
for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) {
const merged = settings.merged[section] as
| Record<string, unknown>
| undefined;
expect(merged?.[key]).toBeUndefined();
expect(
warnings.some((warning) => warning.includes(`${section}.${key}`)),
).toBe(true);
}
});

it('exposes every key in dotted form for the dialog filter', () => {
expect(WORKSPACE_RESTRICTED_SETTING_KEYS).toEqual(
WORKSPACE_RESTRICTED_SETTINGS.map(
({ section, key }) => `${section}.${key}`,
),
);
expect(WORKSPACE_RESTRICTED_SETTING_KEYS).toContain(
'tools.workflowsEnabled',
);
});
});

describe('allowedInsecureVoiceBaseUrls scope handling', () => {
it('should honor the allowlist from user scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
Expand Down
Loading
Loading