From a064e5d0cb3326c8d619a3dcf4dcecbda8e1e7e3 Mon Sep 17 00:00:00 2001 From: ChiGao Date: Fri, 17 Jul 2026 23:27:27 +0800 Subject: [PATCH] feat(core): Enable artifact defaults and write reminders (#7068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): remind models to record artifact writes * feat(core): enable artifacts by default * test(core): cover artifact reminder extension casing --------- Co-authored-by: 秦奇 --- docs/users/configuration/settings.md | 14 ++--- packages/cli/src/config/config.test.ts | 26 ++++++++ packages/cli/src/config/config.ts | 2 +- packages/cli/src/config/settingsSchema.ts | 6 +- packages/core/src/config/config.test.ts | 28 ++++++--- packages/core/src/config/config.ts | 14 ++--- packages/core/src/tools/write-file.test.ts | 61 +++++++++++++++++++ packages/core/src/tools/write-file.ts | 45 ++++++++++++++ .../schemas/settings.schema.json | 6 +- 9 files changed, 170 insertions(+), 32 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 4f4ffc30da9..baa4b15e981 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -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 diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index e5bb45e9413..a478f6fc28f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 62373e211b1..559cf7e5d18 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0692dc4a841..e5026bdbaef 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3917650c5a6..9f3ae1dae28 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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, }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5c1c642c481..4d4fd887d17 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index a8a265badd4..47e16e750f8 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -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: 'Weather', + }; + + 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: 'Dashboard', + }; + + 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: 'Outside', + }; + + 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 () => { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 800d7b52914..492aa565f23 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -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 */ diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c6b6e9e00ab..6324e45de2c 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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 `●