mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(core): Enable artifact defaults and write reminders (#7068)
* fix(core): remind models to record artifact writes * feat(core): enable artifacts by default * test(core): cover artifact reminder extension casing --------- Co-authored-by: 秦奇 <[email protected]>
This commit is contained in:
1 parent
d1715f7efe
commit
a064e5d0cb
9 files changed
+170
-32
No files matched your search
@@ -547,13 +547,13 @@ LSP server configuration is done through `.lsp.json` files in your project root
|
||||
>
|
||||
> **Experimental features.** These toggles gate in-development capabilities and may change or be removed in future releases.
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| -------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
|
||||
| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` |
|
||||
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
|
||||
| `experimental.artifact` | boolean | Enable the Artifact tool, letting the model publish a self-contained HTML page and open it in the browser. Interactive, non-SDK sessions only. `QWEN_CODE_ENABLE_ARTIFACT=1` enables metadata-only `record_artifact` for non-SDK daemon sessions and also enables the Artifact tool in interactive sessions; `QWEN_CODE_DISABLE_ARTIFACT=1` disables both. Requires restart. | `false` |
|
||||
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
|
||||
| Setting | Type | Description | Default |
|
||||
| -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` |
|
||||
| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` |
|
||||
| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` |
|
||||
| `experimental.artifact` | boolean | Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use metadata-only `record_artifact`. Set this to `false` or use `QWEN_CODE_DISABLE_ARTIFACT=1` to disable both. Requires restart. | `true` |
|
||||
| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` |
|
||||
|
||||
#### mcpServers
|
||||
|
||||
|
||||
@@ -1265,6 +1265,32 @@ describe('loadCliConfig', () => {
|
||||
expect(config.shouldAutoOpenArtifact()).toBe(false);
|
||||
});
|
||||
|
||||
it('should enable artifacts by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
|
||||
await loadCliConfig({}, argv);
|
||||
|
||||
expect(mockConfigConstructorParams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifactEnabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate artifact disable setting', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
|
||||
await loadCliConfig({ experimental: { artifact: false } }, argv);
|
||||
|
||||
expect(mockConfigConstructorParams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifactEnabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('places session-injected (ACP/IDE) MCP servers at the top precedence tier', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
|
||||
@@ -2101,7 +2101,7 @@ export async function loadCliConfig(
|
||||
cronEnabled: settings.experimental?.cron ?? true,
|
||||
cronRecurringMaxAgeDays: settings.experimental?.cronRecurringMaxAgeDays,
|
||||
agentTeamEnabled: settings.experimental?.agentTeam ?? false,
|
||||
artifactEnabled: settings.experimental?.artifact ?? false,
|
||||
artifactEnabled: settings.experimental?.artifact ?? true,
|
||||
artifactAutoOpen: settings.artifact?.autoOpen ?? true,
|
||||
artifactPublisher: settings.artifact?.publisher ?? 'local',
|
||||
artifactHost: settings.artifact?.host
|
||||
|
||||
@@ -3184,9 +3184,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Artifacts',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Enable the Artifact tool (experimental). When enabled, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Interactive, non-SDK sessions only. QWEN_CODE_ENABLE_ARTIFACT=1 enables the metadata-only record_artifact tool for non-SDK daemon sessions, and also enables the Artifact tool in interactive sessions. QWEN_CODE_DISABLE_ARTIFACT=1 hard-disables both.',
|
||||
'Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use the metadata-only record_artifact tool. Set this to false or use QWEN_CODE_DISABLE_ARTIFACT=1 to disable both.',
|
||||
showInDialog: true,
|
||||
},
|
||||
emitToolUseSummaries: {
|
||||
@@ -3209,7 +3209,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Configuration for the experimental Artifact tool (enable it via experimental.artifact). Selects the publish backend and, for the host backend, the upload command and shareable URL template.',
|
||||
'Configuration for artifact publishing. Selects the publish backend and, for the host backend, the upload command and shareable URL template.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
autoOpen: {
|
||||
|
||||
@@ -2582,7 +2582,7 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
|
||||
it('does not register artifact tools when artifacts are disabled', async () => {
|
||||
const config = new Config({ ...baseParams });
|
||||
const config = new Config({ ...baseParams, artifactEnabled: false });
|
||||
await config.initialize();
|
||||
|
||||
const registeredNames = (
|
||||
@@ -2592,10 +2592,9 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(registeredNames).not.toContain(ToolNames.RECORD_ARTIFACT);
|
||||
});
|
||||
|
||||
it('registers both artifact tools when artifacts are enabled', async () => {
|
||||
it('registers both artifact tools by default for interactive sessions', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
artifactEnabled: true,
|
||||
interactive: true,
|
||||
sdkMode: false,
|
||||
});
|
||||
@@ -2608,10 +2607,9 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(registeredNames).toContain(ToolNames.RECORD_ARTIFACT);
|
||||
});
|
||||
|
||||
it('registers only record_artifact for daemon artifact metadata', async () => {
|
||||
it('registers only record_artifact by default for daemon artifact metadata', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
artifactEnabled: true,
|
||||
interactive: false,
|
||||
sdkMode: false,
|
||||
});
|
||||
@@ -2646,21 +2644,32 @@ describe('Server Config (config.ts)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('is disabled by default', () => {
|
||||
it('enables metadata recording by default without publishing from daemon sessions', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.isArtifactEnabled()).toBe(false);
|
||||
expect(config.isRecordArtifactEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('honors settings when interactive and not in SDK mode', () => {
|
||||
it('is enabled by default when interactive and not in SDK mode', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
artifactEnabled: true,
|
||||
interactive: true,
|
||||
sdkMode: false,
|
||||
});
|
||||
expect(config.isArtifactEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('honors settings that disable artifacts', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
artifactEnabled: false,
|
||||
interactive: true,
|
||||
sdkMode: false,
|
||||
});
|
||||
expect(config.isArtifactEnabled()).toBe(false);
|
||||
expect(config.isRecordArtifactEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('lets QWEN_CODE_DISABLE_ARTIFACT override settings and env enablement', () => {
|
||||
process.env['QWEN_CODE_DISABLE_ARTIFACT'] = '1';
|
||||
process.env['QWEN_CODE_ENABLE_ARTIFACT'] = '1';
|
||||
@@ -2700,10 +2709,9 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config.isRecordArtifactEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('lets daemon sessions record metadata from settings without publishing', () => {
|
||||
it('lets daemon sessions record metadata by default without publishing', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
artifactEnabled: true,
|
||||
interactive: false,
|
||||
sdkMode: false,
|
||||
});
|
||||
|
||||
@@ -1760,7 +1760,7 @@ export class Config {
|
||||
* (the setting declares `requiresRestart`); `Infinity` = no expiry. */
|
||||
private readonly cronRecurringMaxAgeDays: number;
|
||||
private readonly agentTeamEnabled: boolean = false;
|
||||
private readonly artifactEnabled: boolean = false;
|
||||
private readonly artifactEnabled: boolean = true;
|
||||
private readonly artifactAutoOpen: boolean = true;
|
||||
private readonly artifactPublisher: 'local' | 'host' | 'oss' = 'local';
|
||||
private readonly artifactHost?: ArtifactHostConfig;
|
||||
@@ -2011,7 +2011,7 @@ export class Config {
|
||||
params.cronRecurringMaxAgeDays,
|
||||
);
|
||||
this.agentTeamEnabled = params.agentTeamEnabled ?? false;
|
||||
this.artifactEnabled = params.artifactEnabled ?? false;
|
||||
this.artifactEnabled = params.artifactEnabled ?? true;
|
||||
this.artifactAutoOpen = params.artifactAutoOpen ?? true;
|
||||
this.artifactPublisher = params.artifactPublisher ?? 'local';
|
||||
this.artifactHost = params.artifactHost;
|
||||
@@ -5385,12 +5385,10 @@ export class Config {
|
||||
}
|
||||
|
||||
isArtifactEnabled(): boolean {
|
||||
// Artifacts are experimental and opt-in. Publishing writes outside the
|
||||
// project and opens a browser, so it is limited to interactive, non-SDK
|
||||
// sessions. QWEN_CODE_DISABLE_ARTIFACT hard-disables both artifact tools;
|
||||
// QWEN_CODE_ENABLE_ARTIFACT force-enables interactive artifact tooling
|
||||
// here. isRecordArtifactEnabled() also treats it as an opt-in for the
|
||||
// metadata-only daemon record_artifact tool.
|
||||
// Publishing writes outside the project and opens a browser, so it is
|
||||
// limited to interactive, non-SDK sessions. QWEN_CODE_DISABLE_ARTIFACT
|
||||
// hard-disables both artifact tools; QWEN_CODE_ENABLE_ARTIFACT remains as
|
||||
// a compatibility override for old configs that explicitly disabled them.
|
||||
if (process.env['QWEN_CODE_DISABLE_ARTIFACT'] === '1') return false;
|
||||
if (this.sdkMode) return false;
|
||||
if (!this.interactive) return false;
|
||||
|
||||
@@ -75,6 +75,7 @@ const mockConfigInternal = {
|
||||
getFileReadCache: () => fileReadCache,
|
||||
getFileReadCacheDisabled: () => false,
|
||||
getFileHistoryService: () => mockFileHistoryService,
|
||||
isRecordArtifactEnabled: vi.fn(() => false),
|
||||
};
|
||||
const mockConfig = mockConfigInternal as unknown as Config;
|
||||
|
||||
@@ -121,6 +122,7 @@ describe('WriteFileTool', () => {
|
||||
// Reset mocks before each test
|
||||
mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
mockConfigInternal.setApprovalMode.mockClear();
|
||||
mockConfigInternal.isRecordArtifactEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -445,6 +447,65 @@ describe('WriteFileTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reminds the model to record artifact-like workspace files', async () => {
|
||||
mockConfigInternal.isRecordArtifactEnabled.mockReturnValue(true);
|
||||
const filePath = path.join(rootDir, 'reports', 'weather.html');
|
||||
const params = {
|
||||
file_path: filePath,
|
||||
content: '<!doctype html><html><body>Weather</body></html>',
|
||||
};
|
||||
|
||||
const result = await tool.build(params).execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('record_artifact');
|
||||
expect(result.llmContent).toContain(
|
||||
'workspacePath "reports/weather.html"',
|
||||
);
|
||||
expect(result.artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reminds for case-insensitive artifact extensions', async () => {
|
||||
mockConfigInternal.isRecordArtifactEnabled.mockReturnValue(true);
|
||||
const filePath = path.join(rootDir, 'reports', 'dashboard.HTML');
|
||||
const params = {
|
||||
file_path: filePath,
|
||||
content: '<!doctype html><html><body>Dashboard</body></html>',
|
||||
};
|
||||
|
||||
const result = await tool.build(params).execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('record_artifact');
|
||||
expect(result.llmContent).toContain(
|
||||
'workspacePath "reports/dashboard.HTML"',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not remind for ordinary source files', async () => {
|
||||
mockConfigInternal.isRecordArtifactEnabled.mockReturnValue(true);
|
||||
const filePath = path.join(rootDir, 'src', 'index.ts');
|
||||
const params = {
|
||||
file_path: filePath,
|
||||
content: 'export const value = 1;\n',
|
||||
};
|
||||
|
||||
const result = await tool.build(params).execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).not.toContain('record_artifact');
|
||||
});
|
||||
|
||||
it('does not remind for files outside the workspace', async () => {
|
||||
mockConfigInternal.isRecordArtifactEnabled.mockReturnValue(true);
|
||||
const filePath = path.join(tempDir, 'outside.html');
|
||||
const params = {
|
||||
file_path: filePath,
|
||||
content: '<!doctype html><html><body>Outside</body></html>',
|
||||
};
|
||||
|
||||
const result = await tool.build(params).execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).not.toContain('record_artifact');
|
||||
});
|
||||
|
||||
// trackEdit is best-effort: a FileHistoryService failure (disk full,
|
||||
// permissions, corrupted state) must never break the write_file tool.
|
||||
it('completes the write even when trackEdit throws', async () => {
|
||||
|
||||
@@ -53,6 +53,17 @@ import { CommitAttributionService } from '../services/commitAttribution.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
const debugLogger = createDebugLogger('WRITE_FILE');
|
||||
const ARTIFACT_LIKE_EXTENSIONS = new Set([
|
||||
'.htm',
|
||||
'.html',
|
||||
'.ipynb',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.pdf',
|
||||
'.png',
|
||||
'.svg',
|
||||
'.webp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parameters for the WriteFile tool
|
||||
@@ -550,6 +561,13 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
`User modified the \`content\` to be: ${content}`,
|
||||
);
|
||||
}
|
||||
const artifactReminder = buildRecordArtifactReminder(
|
||||
this.config,
|
||||
file_path,
|
||||
);
|
||||
if (artifactReminder) {
|
||||
llmSuccessMessageParts.push(artifactReminder);
|
||||
}
|
||||
|
||||
// Log file operation for telemetry (without diff_stat to avoid double-counting)
|
||||
const mimetype = getSpecificMimeType(file_path);
|
||||
@@ -625,6 +643,33 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecordArtifactReminder(
|
||||
config: Config,
|
||||
filePath: string,
|
||||
): string | null {
|
||||
if (!config.isRecordArtifactEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (!ARTIFACT_LIKE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = path.relative(config.getTargetDir(), filePath);
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath === '..' ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const workspacePath = relativePath.split(path.sep).join('/');
|
||||
return (
|
||||
`If this file is a reusable user-facing artifact, call ` +
|
||||
`record_artifact with workspacePath "${workspacePath}" before telling ` +
|
||||
`the user it is available in the artifacts panel.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the WriteFile tool logic
|
||||
*/
|
||||
|
||||
@@ -2951,9 +2951,9 @@
|
||||
"default": false
|
||||
},
|
||||
"artifact": {
|
||||
"description": "Enable the Artifact tool (experimental). When enabled, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Interactive, non-SDK sessions only. QWEN_CODE_ENABLE_ARTIFACT=1 enables the metadata-only record_artifact tool for non-SDK daemon sessions, and also enables the Artifact tool in interactive sessions. QWEN_CODE_DISABLE_ARTIFACT=1 hard-disables both.",
|
||||
"description": "Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use the metadata-only record_artifact tool. Set this to false or use QWEN_CODE_DISABLE_ARTIFACT=1 to disable both.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
"default": true
|
||||
},
|
||||
"emitToolUseSummaries": {
|
||||
"description": "Generate a short LLM-based label after each tool batch completes. For a completed tool group the label replaces the generic `Tool × N` header; when the group is force-expanded it appears as a dim `● <label>` line below the tool group. Requires a fast model to be configured; runs in parallel with the next API call so latency is hidden. Currently affects interactive CLI rendering only — SDK / non-interactive emission of the `tool_use_summary` message is not yet wired (the message factory is exported for a follow-up PR). Can be overridden with QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0 or =1.",
|
||||
@@ -2963,7 +2963,7 @@
|
||||
}
|
||||
},
|
||||
"artifact": {
|
||||
"description": "Configuration for the experimental Artifact tool (enable it via experimental.artifact). Selects the publish backend and, for the host backend, the upload command and shareable URL template.",
|
||||
"description": "Configuration for artifact publishing. Selects the publish backend and, for the host backend, the upload command and shareable URL template.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"autoOpen": {
|
||||
|
||||
Reference in new issue
Block a user