diff --git a/scripts/qwen-adapt-agents.js b/scripts/qwen-adapt-agents.js index 855e3ff51..c4b6133df 100644 --- a/scripts/qwen-adapt-agents.js +++ b/scripts/qwen-adapt-agents.js @@ -173,7 +173,7 @@ function adaptFrontmatter(text) { changed = true; continue; } - const color = adaptColor(line); + const color = line === line.trimStart() ? adaptColor(line) : null; if (color) { if (color.changed) { changed = true; @@ -185,8 +185,10 @@ function adaptFrontmatter(text) { } if (/^tools\s*:/.test(line) && hasTools) { - // Consume indented YAML continuations before writing the native scalar. - while (index + 1 < lines.length && /^(?:\s|#|$)/.test(lines[index + 1])) { + // YAML permits sequence items at the same indentation as their key. + // Consume both list styles before publishing the native scalar. + while (index + 1 < lines.length + && /^(?:\s|#|$|-(?:\s|$))/.test(lines[index + 1])) { index += 1; changed = true; } @@ -216,6 +218,14 @@ function adaptFrontmatter(text) { return { text, changed: false }; } + // Aliases or other unsupported forms must fail during preflight, never + // leave an invalid definition in either a standalone or managed install. + try { + yaml.load(updatedLines.join('\n')); + } catch (_error) { + throw new Error('Unsupported Qwen frontmatter conversion: transformed YAML is invalid; use a plain tools list'); + } + const adapted = `---\n${updatedLines.join('\n')}\n---${match[2]}${source.slice(match[0].length)}`; return { diff --git a/tests/scripts/qwen-adapt-agents.test.js b/tests/scripts/qwen-adapt-agents.test.js index 542d28da2..2dc91d797 100644 --- a/tests/scripts/qwen-adapt-agents.test.js +++ b/tests/scripts/qwen-adapt-agents.test.js @@ -413,6 +413,74 @@ function runTests() { assert.strictEqual(adaptFrontmatter(result.text).changed, false); })) passed++; else failed++; + if (test('converts indentless tool sequences through the actual CLI without changing neighboring metadata', () => { + const tempDir = createTempDir(); + const yaml = require('js-yaml'); + try { + for (const [name, items] of [['plain.md', '- Read\n- Grep'], ['comments.md', '- Read\n# Keep the list valid\n- Grep']]) { + writeAgent(tempDir, name, [ + '---', `name: ${name.slice(0, -3)}`, 'description: Preserve neighboring metadata', + 'metadata:', ' color: teal', ' owner: operator', + 'tools:', items, 'model: sonnet', '---', 'BODY_SENTINEL', + ].join('\n')); + } + const first = run([tempDir]); + assert.strictEqual(first.code, 0, first.stderr); + const beforeRerun = fs.readdirSync(tempDir).sort().map(name => [name, readAgent(tempDir, name)]); + for (const [, text] of beforeRerun) { + const parsed = yaml.load(text.match(/^---\n([\s\S]*?)\n---/)[1]); + assert.strictEqual(parsed.tools, 'read_file, grep_search'); + assert.strictEqual(parsed.model, 'inherit'); + assert.strictEqual(parsed.description, 'Preserve neighboring metadata'); + assert.deepStrictEqual(parsed.metadata, { color: 'teal', owner: 'operator' }); + assert.ok(text.endsWith('BODY_SENTINEL')); + } + assert.strictEqual(run([tempDir]).code, 0); + assert.deepStrictEqual(fs.readdirSync(tempDir).sort().map(name => [name, readAgent(tempDir, name)]), beforeRerun); + } finally { cleanupTempDir(tempDir); } + })) passed++; else failed++; + + if (test('a real managed plan installs parseable indentless tool sequences', () => { + const { createManifestInstallPlan, previewInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor'); + const yaml = require('js-yaml'); + const tempDir = createTempDir(); + try { + const plan = createManifestInstallPlan({ + sourceRoot: path.join(__dirname, '..', '..'), target: 'qwen', + homeDir: path.join(tempDir, 'home'), projectRoot: tempDir, + moduleIds: ['agents-core'], env: { HOME: path.join(tempDir, 'home') }, + }); + const operation = plan.operations.find(item => item.contentTransform === 'qwen-agent-frontmatter'); + assert.ok(operation); + const source = path.join(tempDir, 'source.md'); + fs.writeFileSync(source, ['---', 'name: sequence', 'description: Valid tools', + 'tools:', '- Read', '- Grep', 'model: sonnet', '---', 'MANAGED_BODY'].join('\n')); + operation.sourcePath = source; + previewInstallPlan(plan); + applyInstallPlan(plan); + const installed = fs.readFileSync(operation.destinationPath, 'utf8'); + const parsed = yaml.load(installed.match(/^---\n([\s\S]*?)\n---/)[1]); + assert.strictEqual(parsed.tools, 'read_file, grep_search'); + assert.strictEqual(parsed.model, 'inherit'); + assert.ok(installed.endsWith('MANAGED_BODY')); + assert.ok(fs.existsSync(plan.installStatePath)); + } finally { cleanupTempDir(tempDir); } + })) passed++; else failed++; + + if (test('rejects an invalid converted alias before changing any standalone file', () => { + const tempDir = createTempDir(); + try { + writeAgent(tempDir, 'a-valid.md', ['---', 'name: valid', 'tools: Read', '---', 'BODY'].join('\n')); + writeAgent(tempDir, 'z-alias.md', ['---', 'name: alias', 'tools: &allowed', '- Read', + 'metadata:', ' allowed: *allowed', '---', 'ALIAS_BODY'].join('\n')); + const before = fs.readdirSync(tempDir).sort().map(name => [name, readAgent(tempDir, name)]); + const result = run([tempDir]); + assert.strictEqual(result.code, 1); + assert.match(result.stderr, /transformed YAML is invalid/); + assert.deepStrictEqual(fs.readdirSync(tempDir).sort().map(name => [name, readAgent(tempDir, name)]), before); + } finally { cleanupTempDir(tempDir); } + })) passed++; else failed++; + if (test('rejects uniformly indented mappings without changing neighboring metadata', () => { const tempDir = createTempDir(); try {