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:
ChiGaoand秦奇 authored and GitHub committed 2026-07-17 15:27:27 +00:00
1 parent d1715f7efe
commit a064e5d0cb
9 files changed
+170 -32

No files matched your search

+7 -7
View File
@@ -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
+26
View File
@@ -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();
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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: {
+18 -10
View File
@@ -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,
});
+6 -8
View File
@@ -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 () => {
+45
View File
@@ -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": {