diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts index 604293f9d51..7c9ea4769d9 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { Config } from '@qwen-code/qwen-code-core'; import type { TurnContent, MessageRewriteConfig } from './types.js'; @@ -278,4 +281,65 @@ describe('LlmRewriter', () => { expect(input).not.toContain('上一轮改写结果'); }); }); + + describe('promptFile', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llm-rewriter-promptfile-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function promptOf(rewriter: unknown): string { + return (rewriter as { prompt: string }).prompt; + } + + it('loads a custom prompt from a readable file', () => { + const filePath = join(tempDir, 'prompt.md'); + writeFileSync(filePath, ' custom rewrite prompt '); + + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + promptFile: filePath, + } as MessageRewriteConfig); + + expect(promptOf(rewriter)).toBe('custom rewrite prompt'); + }); + + it('falls back to the default prompt when the file is missing', () => { + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + promptFile: join(tempDir, 'does-not-exist.md'), + } as MessageRewriteConfig); + + expect(promptOf(rewriter)).toContain('rewrites raw coding-agent output'); + }); + + // Regression for #9752: promptFile pointing at a path that exists but + // cannot be read as a file (a directory) used to throw EISDIR from the + // constructor, crashing ACP session startup. + it('falls back to the default prompt when promptFile is a directory', () => { + expect( + () => + new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + promptFile: tempDir, + } as MessageRewriteConfig), + ).not.toThrow(); + + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + promptFile: tempDir, + } as MessageRewriteConfig); + + expect(promptOf(rewriter)).toContain('rewrites raw coding-agent output'); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index 5783497f091..6228e466651 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -57,16 +57,29 @@ export class LlmRewriter { // promptFile takes precedence over inline prompt if (rewriteConfig.promptFile) { const filePath = resolve(rewriteConfig.promptFile); - if (existsSync(filePath)) { - this.prompt = readFileSync(filePath, 'utf-8').trim(); - debugLogger.info( - `Loaded rewrite prompt from file: ${filePath} (${this.prompt.length} chars)`, - ); - } else { + if (!existsSync(filePath)) { debugLogger.warn( `Rewrite prompt file not found: ${filePath}, using default`, ); this.prompt = DEFAULT_REWRITE_PROMPT; + } else { + // existsSync passes for directories and says nothing about + // readability, so the read itself can still fail (EISDIR, EACCES, + // ...). Degrade like the missing-file case instead of throwing, + // which would crash ACP session startup (#9752). + try { + this.prompt = readFileSync(filePath, 'utf-8').trim(); + debugLogger.info( + `Loaded rewrite prompt from file: ${filePath} (${this.prompt.length} chars)`, + ); + } catch (error) { + debugLogger.warn( + `Rewrite prompt file could not be read: ${filePath} (${ + error instanceof Error ? error.message : String(error) + }), using default`, + ); + this.prompt = DEFAULT_REWRITE_PROMPT; + } } } else { this.prompt = rewriteConfig.prompt || DEFAULT_REWRITE_PROMPT; diff --git a/packages/cli/src/acp-integration/session/rewrite/README.md b/packages/cli/src/acp-integration/session/rewrite/README.md index ad40314be12..e327daa097d 100644 --- a/packages/cli/src/acp-integration/session/rewrite/README.md +++ b/packages/cli/src/acp-integration/session/rewrite/README.md @@ -33,3 +33,6 @@ Add to `settings.json`: ``` `timeoutMs` sets the per-rewrite LLM call timeout in milliseconds. Defaults to 30000. +If `promptFile` is missing or cannot be read, rewriting falls back to the +built-in default prompt. Set `QWEN_DEBUG_LOG_FILE` to capture the fallback +warning.