From f8cf0cb7727a404a48144036726d766e17bfcbd4 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Thu, 9 Apr 2026 19:20:44 +0200 Subject: [PATCH] fix(run): add explicit UTF-8 encoding to prompt file operations (#604) All three open() calls in PromptCompiler and ScriptRunner that handle .prompt.md files now specify encoding="utf-8", preventing UnicodeDecodeError on systems where the default locale is not UTF-8 (e.g., Windows CP950). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + src/apm_cli/core/script_runner.py | 6 ++-- tests/unit/test_script_runner.py | 55 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f1f058..7db4be7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Add explicit UTF-8 encoding to prompt file read/write operations to prevent `UnicodeDecodeError` on non-UTF-8 default locales (e.g., Windows CP950) (#604) - Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622) ### Changed diff --git a/src/apm_cli/core/script_runner.py b/src/apm_cli/core/script_runner.py index 87798c5c..fcb919fd 100644 --- a/src/apm_cli/core/script_runner.py +++ b/src/apm_cli/core/script_runner.py @@ -256,7 +256,7 @@ def _auto_compile_prompts( compiled_prompt_files.append(prompt_file) # Read the compiled content - with open(compiled_path, "r") as f: + with open(compiled_path, "r", encoding="utf-8") as f: compiled_content = f.read().strip() # Check if this is a runtime command (copilot, codex, llm) before transformation @@ -916,7 +916,7 @@ def compile(self, prompt_file: str, params: Dict[str, str]) -> str: # Now ensure compiled directory exists self.compiled_dir.mkdir(parents=True, exist_ok=True) - with open(prompt_path, "r") as f: + with open(prompt_path, "r", encoding="utf-8") as f: content = f.read() # Parse frontmatter and content @@ -939,7 +939,7 @@ def compile(self, prompt_file: str, params: Dict[str, str]) -> str: output_path = self.compiled_dir / output_name # Write compiled content - with open(output_path, "w") as f: + with open(output_path, "w", encoding="utf-8") as f: f.write(compiled_content) return str(output_path) diff --git a/tests/unit/test_script_runner.py b/tests/unit/test_script_runner.py index 31c79b0a..af44baa8 100644 --- a/tests/unit/test_script_runner.py +++ b/tests/unit/test_script_runner.py @@ -325,6 +325,61 @@ def test_compile_file_not_found(self, mock_exists): with pytest.raises(FileNotFoundError, match="Prompt file 'nonexistent.prompt.md' not found"): self.compiler.compile("nonexistent.prompt.md", {}) + def test_compile_utf8_content_with_cjk_characters(self): + """Test that prompt files with non-ASCII characters compile correctly. + + Regression test for #604: UnicodeDecodeError on Windows CP950 + when .prompt.md contains CJK or other non-ASCII characters. + """ + tmp_dir = tempfile.mkdtemp() + try: + prompt_dir = Path(tmp_dir) + prompt_path = prompt_dir / "i18n.prompt.md" + cjk_content = ( + "---\n" + "description: 国際化テスト\n" + "---\n" + "\n" + "你好世界!こんにちは ${input:name}!\n" + "Ünïcödé résumé naïve café" + ) + prompt_path.write_text(cjk_content, encoding="utf-8") + + compiler = PromptCompiler() + compiler.compiled_dir = prompt_dir / ".compiled" + + result_path = compiler.compile( + str(prompt_path), {"name": "ユーザー"} + ) + + compiled = Path(result_path).read_text(encoding="utf-8") + assert "你好世界!こんにちは ユーザー!" in compiled + assert "Ünïcödé résumé naïve café" in compiled + # Frontmatter must be stripped + assert "---" not in compiled + finally: + shutil.rmtree(tmp_dir) + + def test_compile_utf8_content_without_frontmatter(self): + """Test non-ASCII prompt without frontmatter compiles correctly.""" + tmp_dir = tempfile.mkdtemp() + try: + prompt_dir = Path(tmp_dir) + prompt_path = prompt_dir / "simple_cjk.prompt.md" + prompt_path.write_text( + "Привет ${input:who}! 🚀", encoding="utf-8" + ) + + compiler = PromptCompiler() + compiler.compiled_dir = prompt_dir / ".compiled" + + result_path = compiler.compile(str(prompt_path), {"who": "мир"}) + + compiled = Path(result_path).read_text(encoding="utf-8") + assert compiled == "Привет мир! 🚀" + finally: + shutil.rmtree(tmp_dir) + class TestPromptCompilerDependencyDiscovery: """Test PromptCompiler dependency discovery functionality."""