# -*- coding: utf-8 -*- """Tests for the restricted local CLI generation backend.""" from __future__ import annotations from concurrent.futures import ThreadPoolExecutor import json import os from pathlib import Path import subprocess import sys import time from types import SimpleNamespace import pytest from tests.litellm_stub import ensure_litellm_stub ensure_litellm_stub() from src.analyzer import GeminiAnalyzer # noqa: E402 from src.llm import local_cli_backend as local_cli_backend_module # noqa: E402 from src.llm.generation_backend import GenerationError, GenerationErrorCode # noqa: E402 from src.llm.local_cli_backend import ( # noqa: E402 CLAUDE_CODE_CLI_PRESET, LocalCliGenerationBackend, LocalCliExecutionResult, LocalCliExtractionError, LocalCliPreset, OPENCODE_CLI_PRESET, build_local_cli_env, effective_local_cli_concurrency, redact_diagnostic_text, ) def _config(**overrides): defaults = { "generation_backend_timeout_seconds": 5, "generation_backend_max_output_bytes": 1024 * 1024, "generation_backend_max_concurrency": 1, "local_cli_backend_max_concurrency": 1, "generation_backend": "codex_cli", "generation_fallback_backend": "", "report_language": "zh", } defaults.update(overrides) return SimpleNamespace(**defaults) def _script(tmp_path: Path, source: str) -> str: path = tmp_path / "mock_cli.py" path.write_text(source, encoding="utf-8") return str(path) def _backend(tmp_path: Path, source: str, **config_overrides) -> LocalCliGenerationBackend: preset = LocalCliPreset( preset_id="codex_cli", executable=sys.executable, argv=(_script(tmp_path, source),), display_name="Mock CLI", ) return LocalCliGenerationBackend(_config(**config_overrides), preset=preset) def test_success_uses_stdin_temp_cwd_and_usage_unavailable(tmp_path: Path) -> None: backend = _backend( tmp_path, """ import json, os, sys prompt = sys.stdin.read() print(json.dumps({"prompt": prompt, "cwd": os.getcwd(), "sentiment_score": 70}, ensure_ascii=False)) """, ) result = backend.generate("hello", {}, response_validator=lambda text: json.loads(text)) payload = json.loads(result.text) assert payload["prompt"] == "hello" assert payload["cwd"] != os.getcwd() assert not Path(payload["cwd"]).exists() assert result.usage == { "usage_available": False, "usage_source": "unavailable", "backend": "codex_cli", } assert result.diagnostics["executable"]["basename"] == Path(sys.executable).name assert "path" not in result.diagnostics["executable"] def test_codex_preset_reads_output_last_message_instead_of_stdout(tmp_path: Path) -> None: final_payload = json.dumps({"prompt": "hello", "sentiment_score": 88, "source": "last_message"}) script = _script( tmp_path, f""" import json, sys args = sys.argv[1:] output_path = args[args.index("--output-last-message") + 1] prompt = sys.stdin.read() with open(output_path, "w", encoding="utf-8") as handle: handle.write(json.dumps({{"prompt": prompt, "sentiment_score": 88, "source": "last_message"}})) print("OpenAI Codex v0.142.0") print("23,011") print({final_payload!r}) """, ) preset = LocalCliPreset( preset_id="codex_cli", executable=sys.executable, argv=(script, "-"), display_name="Mock Codex CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(), preset=preset) result = backend.generate("hello", {}, response_validator=lambda text: json.loads(text)) payload = json.loads(result.text) assert payload == { "prompt": "hello", "sentiment_score": 88, "source": "last_message", } assert result.diagnostics["output_source"] == "output_last_message" assert "OpenAI Codex" in result.diagnostics["stdout_preview"] assert "final-message omitted" in result.diagnostics["stdout_preview"] assert "last_message" not in result.diagnostics["stdout_preview"] def test_claude_preset_runtime_argv_contains_contract_args(tmp_path: Path) -> None: argv_path = tmp_path / "argv.json" script = _script( tmp_path, f""" import json, pathlib, sys path = pathlib.Path({str(argv_path)!r}) path.write_text(json.dumps(sys.argv[1:]), encoding="utf-8") print(json.dumps({{"type": "result", "subtype": "success", "result": "{{\\"sentiment_score\\": 77}}"}})) """, ) preset = LocalCliPreset( preset_id="claude_code_cli", executable=sys.executable, argv=(script, *CLAUDE_CODE_CLI_PRESET.argv), display_name="Mock Claude Code CLI", extractor=CLAUDE_CODE_CLI_PRESET.extractor, contract_args=CLAUDE_CODE_CLI_PRESET.contract_args, ) backend = LocalCliGenerationBackend( _config(generation_backend="claude_code_cli"), preset=preset, ) result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text)) runtime_argv = json.loads(argv_path.read_text(encoding="utf-8")) assert json.loads(result.text)["sentiment_score"] == 77 for contract_arg in CLAUDE_CODE_CLI_PRESET.contract_args: assert contract_arg in runtime_argv def test_missing_contract_arg_is_capability_unsupported(tmp_path: Path) -> None: preset = LocalCliPreset( preset_id="claude_code_cli", executable=sys.executable, argv=(_script(tmp_path, "print('unused')"), "--safe-mode"), display_name="Mock Claude Code CLI", contract_args=("--safe-mode", "--strict-mcp-config"), ) backend = LocalCliGenerationBackend( _config(generation_backend="claude_code_cli"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED assert exc_info.value.details["reason"] == "missing_runtime_contract_arg" assert "--strict-mcp-config" in exc_info.value.details["missing_contract_args"] def test_contract_args_must_keep_preset_order(tmp_path: Path) -> None: preset = LocalCliPreset( preset_id="claude_code_cli", executable=sys.executable, argv=( _script(tmp_path, "print('unused')"), "--strict-mcp-config", "--safe-mode", ), display_name="Mock Claude Code CLI", contract_args=("--safe-mode", "--strict-mcp-config"), ) backend = LocalCliGenerationBackend( _config(generation_backend="claude_code_cli"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED assert exc_info.value.details["reason"] == "missing_runtime_contract_arg" assert "--strict-mcp-config" in exc_info.value.details["missing_contract_args"] def test_claude_extractor_uses_structured_output_in_schema_mode() -> None: preset = LocalCliPreset( preset_id="claude_code_cli", executable="claude", argv=(), display_name="Mock Claude Code CLI", extractor=lambda result: local_cli_backend_module._extract_claude_code_json( result, schema_mode=True, ), ) text = preset.extractor( LocalCliExecutionResult( stdout=json.dumps({ "type": "result", "subtype": "success", "structured_output": {"sentiment_score": "70"}, }), stderr="", returncode=0, ) ) assert text == '{"sentiment_score":"70"}' def test_claude_extractor_rejects_tool_event() -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_claude_code_json( LocalCliExecutionResult( stdout=json.dumps({"type": "tool_use", "result": "should not parse"}), stderr="", returncode=0, ), schema_mode=False, ) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED assert exc_info.value.reason == "unexpected_cli_event" def test_claude_extractor_requires_result_success_envelope() -> None: with pytest.raises(LocalCliExtractionError) as missing_type: local_cli_backend_module._extract_claude_code_json( LocalCliExecutionResult( stdout=json.dumps({"subtype": "success", "result": "should not parse"}), stderr="", returncode=0, ), schema_mode=False, ) with pytest.raises(LocalCliExtractionError) as missing_subtype: local_cli_backend_module._extract_claude_code_json( LocalCliExecutionResult( stdout=json.dumps({"type": "result", "result": "should not parse"}), stderr="", returncode=0, ), schema_mode=False, ) assert missing_type.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED assert missing_type.value.reason == "unexpected_cli_event" assert missing_subtype.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR assert missing_subtype.value.reason == "cli_result_not_success" def test_claude_schema_retry_exhaustion_maps_schema_validation_failed() -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_claude_code_json( LocalCliExecutionResult( stdout=json.dumps({ "type": "result", "subtype": "error_max_structured_output_retries", "is_error": True, }), stderr="", returncode=0, ), schema_mode=True, ) assert exc_info.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED def test_opencode_preset_uses_prompt_file_and_safe_argv(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-should-not-leak") monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-leak") monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", '{"plugin":["leak"]}') argv_path = tmp_path / "argv.json" probe_path = tmp_path / "probe.json" script = _script( tmp_path, f""" import json, os, pathlib, stat, sys argv = sys.argv[1:] prompt_path = pathlib.Path(argv[argv.index("--file") + 1]) config_path = pathlib.Path.cwd() / "opencode.json" probe = {{ "argv": argv, "prompt": prompt_path.read_text(encoding="utf-8"), "prompt_mode": stat.S_IMODE(prompt_path.stat().st_mode), "cwd_mode": stat.S_IMODE(pathlib.Path.cwd().stat().st_mode), "config": config_path.read_text(encoding="utf-8"), "env": {{ "DEEPSEEK_API_KEY": os.environ.get("DEEPSEEK_API_KEY"), "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY"), "OPENCODE_CONFIG_CONTENT": os.environ.get("OPENCODE_CONFIG_CONTENT"), "OPENCODE_CONFIG_DIR": os.environ.get("OPENCODE_CONFIG_DIR"), }}, }} pathlib.Path({str(argv_path)!r}).write_text(json.dumps(argv), encoding="utf-8") pathlib.Path({str(probe_path)!r}).write_text(json.dumps(probe), encoding="utf-8") print(json.dumps({{"type": "step_start"}})) print(json.dumps({{"type": "text", "part": {{"text": "{{\\"sentiment_score\\": 66}}"}}}})) print(json.dumps({{"type": "step_finish", "reason": "stop"}})) """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli"), preset=preset, ) result = backend.generate("prompt from dsa", {}, response_validator=lambda text: json.loads(text)) payload = json.loads(result.text) argv = json.loads(argv_path.read_text(encoding="utf-8")) probe = json.loads(probe_path.read_text(encoding="utf-8")) opencode_config = json.loads(probe["config"]) assert payload["sentiment_score"] == 66 assert argv[:4] == ["--pure", "run", "--format", "json"] assert "--model" not in argv assert "--file" in argv assert argv.index("--file") > argv.index("json") assert "--attach" not in argv assert "--dangerously-skip-permissions" not in argv assert probe["prompt"] == "prompt from dsa" assert probe["prompt_mode"] == 0o600 assert probe["cwd_mode"] == 0o700 for tool_name in local_cli_backend_module._OPENCODE_DISABLED_TOOL_NAMES: assert opencode_config["tools"][tool_name] is False assert opencode_config["tools"]["websearch"] is False assert opencode_config["tools"]["question"] is False assert opencode_config["tools"]["skill"] is False assert opencode_config["tools"]["todowrite"] is False assert opencode_config["tools"]["lsp"] is False assert "sk-should-not-leak" not in probe["config"] assert "sk-openai-should-not-leak" not in probe["config"] assert probe["env"]["DEEPSEEK_API_KEY"] is None assert probe["env"]["OPENAI_API_KEY"] is None assert probe["env"]["OPENCODE_CONFIG_CONTENT"] is None assert probe["env"]["OPENCODE_CONFIG_DIR"] is None assert result.diagnostics["opencode_project_config_written"] is True assert "opencode_config_controlled" not in result.diagnostics assert result.backend == "opencode_cli" assert result.provider == "opencode_cli" assert result.model == "opencode_cli" assert result.usage["backend"] == "opencode_cli" def test_opencode_static_instruction_does_not_force_stock_json_contract() -> None: instruction = " ".join(str(arg) for arg in OPENCODE_CLI_PRESET.argv) normalized_instruction = instruction.lower() assert "attached prompt file" in instruction assert "json object" not in normalized_instruction assert "parser contract" not in normalized_instruction for field_name in ( "sentiment_score", "trend_prediction", "operation_advice", "analysis_summary", "dashboard", ): assert field_name not in instruction def test_opencode_preset_accepts_free_text_without_json_validator(tmp_path: Path) -> None: review = "## 今日复盘\n\n市场震荡,保持观察。" script = _script( tmp_path, f""" import json print(json.dumps({{"type": "step_start"}})) print(json.dumps({{"type": "text", "part": {{"text": {review!r}}}}}, ensure_ascii=False)) print(json.dumps({{"type": "step_finish", "reason": "stop"}})) """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli"), preset=preset, ) result = backend.generate("请生成 Markdown 复盘", {}, response_validator=None) assert result.text == review def test_opencode_model_override_inserts_model_arg(tmp_path: Path) -> None: argv_path = tmp_path / "argv.json" script = _script( tmp_path, f""" import json, pathlib, sys pathlib.Path({str(argv_path)!r}).write_text(json.dumps(sys.argv[1:]), encoding="utf-8") print(json.dumps({{"type": "step_start"}})) print(json.dumps({{"type": "text", "part": {{"text": "{{\\"sentiment_score\\": 67}}"}}}})) print(json.dumps({{"type": "step_finish", "reason": "stop"}})) """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli", opencode_cli_model="provider/model"), preset=preset, ) result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text)) argv = json.loads(argv_path.read_text(encoding="utf-8")) assert json.loads(result.text)["sentiment_score"] == 67 assert argv[:6] == ["--pure", "run", "--format", "json", "--model", "provider/model"] assert argv.index("--file") > argv.index("provider/model") def test_opencode_nonzero_json_event_error_maps_unknown_backend_error(tmp_path: Path) -> None: script = _script( tmp_path, """ import json print(json.dumps({"type": "error", "error": {"name": "UnknownError"}})) raise SystemExit(1) """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli", opencode_cli_model="provider/model"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR assert exc_info.value.details["reason"] == "cli_result_error" def test_opencode_nonzero_pretty_json_error_maps_unknown_backend_error(tmp_path: Path) -> None: script = _script( tmp_path, """ import json print(json.dumps({"type": "error", "error": {"name": "UnknownError"}}, indent=2)) raise SystemExit(1) """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli", opencode_cli_model="provider/model"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR assert exc_info.value.details["reason"] == "cli_result_error" @pytest.mark.parametrize( ("event", "stream_name"), [ ({"type": "tool_use", "name": "read"}, "stdout"), ({"type": "websearch", "query": "AAPL"}, "stdout"), ({"type": "tool_result", "part": {"tool_name": "todowrite"}}, "stdout"), ({"type": "lsp", "name": "diagnostics"}, "stdout"), ({"type": "question", "text": "Continue?"}, "stdout"), ({"type": "permission", "name": "read"}, "stdout"), ({"type": "step_finish", "is_error": True}, "stdout"), ({"type": "step_finish", "error": {"name": "StepFailed"}}, "stdout"), ({"type": "error", "error": {"name": "StderrError"}}, "stderr"), ], ) def test_opencode_nonzero_blocked_or_error_event_maps_unknown_backend_error( tmp_path: Path, event: dict, stream_name: str, ) -> None: target_stream = "sys.stderr" if stream_name == "stderr" else "sys.stdout" script = _script( tmp_path, f""" import json, sys print(json.dumps({event!r}), file={target_stream}) raise SystemExit(1) """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli", opencode_cli_model="provider/model"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR assert exc_info.value.details["reason"] == "cli_result_error" def test_opencode_runtime_rejects_unsafe_model_override(tmp_path: Path) -> None: script = _script( tmp_path, """ print("should not execute") """, ) preset = LocalCliPreset( preset_id="opencode_cli", executable=sys.executable, argv=(script, *OPENCODE_CLI_PRESET.argv), display_name="Mock OpenCode CLI", extractor=OPENCODE_CLI_PRESET.extractor, contract_args=OPENCODE_CLI_PRESET.contract_args, prompt_transport=OPENCODE_CLI_PRESET.prompt_transport, ) backend = LocalCliGenerationBackend( _config(generation_backend="opencode_cli", opencode_cli_model="provider/$MODEL"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNSAFE_CONFIG assert exc_info.value.details["reason"] == "unsafe_opencode_cli_model" def test_opencode_extractor_rejects_tool_event() -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout="\n".join([ json.dumps({"type": "step_start"}), json.dumps({"type": "tool_use", "name": "read"}), json.dumps({"type": "step_finish", "reason": "stop"}), ]), stderr="", returncode=0, ) ) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED @pytest.mark.parametrize( "event", [ {"type": "websearch", "query": "AAPL"}, {"type": "question", "text": "Continue?"}, {"type": "skill", "name": "default"}, {"type": "todowrite", "items": []}, {"type": "lsp", "name": "diagnostics"}, {"type": "tool_use", "name": "websearch"}, {"type": "tool_result", "part": {"tool_name": "todowrite"}}, ], ) def test_opencode_extractor_rejects_default_tool_events(event: dict) -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout="\n".join([ json.dumps({"type": "step_start"}), json.dumps(event), json.dumps({"type": "step_finish", "reason": "stop"}), ]), stderr="", returncode=0, ) ) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED def test_opencode_event_iterator_accepts_pretty_single_event_object() -> None: events = list(local_cli_backend_module._iter_opencode_events( json.dumps({"type": "step_start"}, indent=2) )) assert events == [{"type": "step_start"}] def test_opencode_extractor_accepts_json_array_event_stream() -> None: result = local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout=json.dumps([ {"type": "step_start"}, {"type": "text", "text": '{"sentiment_score":'}, {"type": "text", "part": {"text": " 68}"}}, {"type": "step_finish", "reason": "stop"}, ], indent=2), stderr="", returncode=0, ) ) assert json.loads(result)["sentiment_score"] == 68 def test_opencode_extractor_accepts_concatenated_event_stream() -> None: stdout = "".join([ json.dumps({"type": "step_start"}), json.dumps({"type": "text", "text": '{"sentiment_score":'}), json.dumps({"type": "text", "part": {"text": " 69}"}}), json.dumps({"type": "step_finish", "reason": "end_turn"}), ]) result = local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult(stdout=stdout, stderr="", returncode=0) ) assert json.loads(result)["sentiment_score"] == 69 def test_opencode_extractor_rejects_trailing_non_json_garbage() -> None: stdout = json.dumps({"type": "step_start"}) + " trailing" with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult(stdout=stdout, stderr="", returncode=0) ) assert exc_info.value.error_code is GenerationErrorCode.INVALID_JSON def test_opencode_extractor_rejects_non_event_json_shape() -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult(stdout=json.dumps({"message": "not an event"}), stderr="", returncode=0) ) assert exc_info.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED def test_opencode_extractor_rejects_array_without_step_finish() -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout=json.dumps([ {"type": "step_start"}, {"type": "text", "text": "hello"}, ]), stderr="", returncode=0, ) ) assert exc_info.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED assert exc_info.value.reason == "missing_step_finish" def test_opencode_extractor_rejects_later_error_after_step_finish() -> None: with pytest.raises(LocalCliExtractionError) as exc_info: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout=json.dumps([ {"type": "step_start"}, {"type": "text", "text": "hello"}, {"type": "step_finish", "reason": "stop"}, {"type": "error", "error": {"name": "LaterError"}}, ]), stderr="", returncode=0, ) ) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED def test_opencode_extractor_requires_step_finish_and_text() -> None: with pytest.raises(LocalCliExtractionError) as missing_finish: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout=json.dumps({"type": "text", "text": "hello"}), stderr="", returncode=0, ) ) with pytest.raises(LocalCliExtractionError) as empty_text: local_cli_backend_module._extract_opencode_json_events( LocalCliExecutionResult( stdout="\n".join([ json.dumps({"type": "step_start"}), json.dumps({"type": "step_finish", "reason": "stop"}), ]), stderr="", returncode=0, ) ) assert missing_finish.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED assert empty_text.value.error_code is GenerationErrorCode.EMPTY_OUTPUT def test_output_last_message_stdout_duplicate_is_not_double_counted(tmp_path: Path) -> None: final_payload = json.dumps( { "sentiment_score": 70, "source": "last_message", "details": "x" * 40, } ) script = _script( tmp_path, f""" import sys args = sys.argv[1:] output_path = args[args.index("--output-last-message") + 1] with open(output_path, "w", encoding="utf-8") as handle: handle.write({final_payload!r}) print({final_payload!r}) """, ) preset = LocalCliPreset( "codex_cli", sys.executable, (script,), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend( _config(generation_backend_max_output_bytes=len(final_payload.encode("utf-8")) + 2), preset=preset, ) result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text)) assert json.loads(result.text)["sentiment_score"] == 70 assert result.diagnostics["stdout_final_message_omitted"] is True assert "last_message" not in result.diagnostics["stdout_preview"] def test_output_last_message_nonzero_exit_omits_duplicate_final_stdout_preview( tmp_path: Path, ) -> None: final_payload = json.dumps( { "sentiment_score": 70, "source": "secret_final_payload", } ) script = _script( tmp_path, f""" import sys args = sys.argv[1:] output_path = args[args.index("--output-last-message") + 1] with open(output_path, "w", encoding="utf-8") as handle: handle.write({final_payload!r}) print("diagnostic: before final") print({final_payload!r}) sys.exit(2) """, ) preset = LocalCliPreset( "codex_cli", sys.executable, (script,), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.NON_ZERO_EXIT assert "diagnostic: before final" in exc_info.value.details["stdout_preview"] assert "final-message omitted" in exc_info.value.details["stdout_preview"] assert "secret_final_payload" not in exc_info.value.details["stdout_preview"] def test_stream_request_degrades_to_non_stream(tmp_path: Path) -> None: progress = [] backend = _backend(tmp_path, "print('{\"sentiment_score\": 60}')") result = backend.generate( "prompt", {}, stream=True, stream_progress_callback=progress.append, ) assert json.loads(result.text)["sentiment_score"] == 60 assert result.diagnostics["stream_degraded"] is True assert progress def test_stderr_does_not_affect_successful_stdout_or_json_parsing(tmp_path: Path) -> None: analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer) analyzer._config_override = _config() backend = _backend( tmp_path, """ import sys print('{"sentiment_score": 70, "trend_prediction": "看多"}') print('{"bad": "stderr"}', file=sys.stderr) """, ) result = backend.generate( "prompt", {}, response_validator=analyzer._validate_json_response, ) assert json.loads(result.text)["sentiment_score"] == 70 assert "stderr" in result.diagnostics["stderr_preview"] def test_multiple_json_objects_fail_as_invalid_json_ambiguous(tmp_path: Path) -> None: analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer) analyzer._config_override = _config() backend = _backend(tmp_path, "print('{\"sentiment_score\": 70} {\"sentiment_score\": 80}')") with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}, response_validator=analyzer._validate_json_response) assert exc_info.value.error_code is GenerationErrorCode.INVALID_JSON assert exc_info.value.details["reason"] == "ambiguous_json" def test_command_not_executable(monkeypatch, tmp_path: Path) -> None: not_exec = tmp_path / "not-executable" not_exec.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setattr("src.llm.local_cli_backend.shutil.which", lambda _cmd: str(not_exec)) preset = LocalCliPreset("codex_cli", "mock", (), "Mock CLI") backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.COMMAND_NOT_EXECUTABLE def test_command_not_found(monkeypatch) -> None: monkeypatch.setattr("src.llm.local_cli_backend.shutil.which", lambda _cmd: None) backend = LocalCliGenerationBackend(_config()) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.COMMAND_NOT_FOUND def test_shell_metachar_returns_unsafe_config() -> None: preset = LocalCliPreset("codex_cli", "mock", ("echo", "ok;rm"), "Mock CLI") backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNSAFE_CONFIG assert exc_info.value.details["reason"] == "shell_metachar" def test_output_last_message_arg_shell_metachar_returns_unsafe_config(tmp_path: Path) -> None: preset = LocalCliPreset( "codex_cli", sys.executable, (_script(tmp_path, "print('ok')"),), "Mock CLI", output_last_message_arg="--output-last-message;rm", ) backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNSAFE_CONFIG assert exc_info.value.details["reason"] == "shell_metachar" def test_output_too_large(tmp_path: Path) -> None: backend = _backend( tmp_path, "print('x' * 100)", generation_backend_max_output_bytes=20, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE def test_output_stat_error_is_structured_and_kills_process_group( tmp_path: Path, monkeypatch, ) -> None: pid_file = tmp_path / "child-stat-error.pid" backend = _backend( tmp_path, f""" import subprocess, sys, time child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) open({str(pid_file)!r}, "w", encoding="utf-8").write(str(child.pid)) sys.stdout.write("started") sys.stdout.flush() time.sleep(30) """, ) def _raise_stat_error(*_paths): deadline = time.time() + 3 while not pid_file.exists() and time.time() < deadline: time.sleep(0.01) raise OSError("mock stat failure sk-secretsecretsecret") monkeypatch.setattr( "src.llm.local_cli_backend._combined_path_size_required", _raise_stat_error, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR assert exc_info.value.details["reason"] == "output_stat_failed" assert "sk-secret" not in exc_info.value.details["error"] child_pid = int(pid_file.read_text(encoding="utf-8")) deadline = time.time() + 3 while time.time() < deadline: try: os.kill(child_pid, 0) except OSError: break time.sleep(0.05) else: pytest.fail("child process was not terminated after output stat failure") def test_output_read_error_is_structured_unknown_not_empty( tmp_path: Path, monkeypatch, ) -> None: backend = _backend(tmp_path, "print('{\"sentiment_score\": 70}')") def _raise_read_error(_path): raise OSError("mock read failure") monkeypatch.setattr( "src.llm.local_cli_backend._read_text_file_required", _raise_read_error, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR assert exc_info.value.details["reason"] == "output_read_failed" def test_stdout_output_limit_is_not_double_counted(tmp_path: Path) -> None: backend = _backend( tmp_path, "print('{\"sentiment_score\": 70}')", generation_backend_max_output_bytes=30, ) result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text)) assert json.loads(result.text)["sentiment_score"] == 70 assert result.diagnostics["output_source"] == "stdout" def test_output_too_large_kills_process_group(tmp_path: Path) -> None: pid_file = tmp_path / "child-output-limit.pid" backend = _backend( tmp_path, f""" import subprocess, sys, time child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) open({str(pid_file)!r}, "w", encoding="utf-8").write(str(child.pid)) sys.stdout.write("x" * 100000) sys.stdout.flush() time.sleep(30) """, generation_backend_max_output_bytes=20, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE child_pid = int(pid_file.read_text(encoding="utf-8")) deadline = time.time() + 3 while time.time() < deadline: try: os.kill(child_pid, 0) except OSError: break time.sleep(0.05) else: pytest.fail("child process was not terminated with the process group") def test_output_last_message_too_large(tmp_path: Path) -> None: script = _script( tmp_path, """ import sys args = sys.argv[1:] output_path = args[args.index("--output-last-message") + 1] with open(output_path, "w", encoding="utf-8") as handle: handle.write("x" * 100) """, ) preset = LocalCliPreset( "codex_cli", sys.executable, (script,), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(generation_backend_max_output_bytes=20), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE def test_output_last_message_total_limit_includes_stdio(tmp_path: Path) -> None: script = _script( tmp_path, """ import sys args = sys.argv[1:] output_path = args[args.index("--output-last-message") + 1] print("stdout bytes") with open(output_path, "w", encoding="utf-8") as handle: handle.write("final bytes") """, ) preset = LocalCliPreset( "codex_cli", sys.executable, (script,), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(generation_backend_max_output_bytes=20), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE def test_empty_stdout_returns_empty_output(tmp_path: Path) -> None: backend = _backend(tmp_path, "") with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.EMPTY_OUTPUT assert exc_info.value.details["reason"] == "empty_stdout" def test_missing_output_last_message_returns_empty_output(tmp_path: Path) -> None: preset = LocalCliPreset( "codex_cli", sys.executable, (_script(tmp_path, "print('metadata only')"),), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.EMPTY_OUTPUT assert exc_info.value.details["reason"] == "missing_last_message_output" assert exc_info.value.details["output_source"] == "output_last_message" def test_non_zero_exit_maps_login_required(tmp_path: Path) -> None: backend = _backend( tmp_path, """ import sys print('not authenticated, please login', file=sys.stderr) raise SystemExit(2) """, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.LOGIN_REQUIRED assert exc_info.value.details["returncode"] == 2 def test_non_zero_exit_maps_cli_contract_unsupported(tmp_path: Path) -> None: preset = LocalCliPreset( "codex_cli", sys.executable, ( _script( tmp_path, """ import sys print("error: unexpected argument '--output-last-message' found", file=sys.stderr) raise SystemExit(2) """, ), ), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED assert exc_info.value.fallbackable is True assert exc_info.value.details["reason"] == "cli_contract_unsupported" assert exc_info.value.details["returncode"] == 2 assert "--output-last-message" in exc_info.value.details["stderr_preview"] def test_claude_unknown_contract_arg_is_capability_unsupported_without_retry(tmp_path: Path) -> None: argv_path = tmp_path / "argv.json" count_path = tmp_path / "count.txt" script = _script( tmp_path, f""" import json, pathlib, sys argv_path = pathlib.Path({str(argv_path)!r}) count_path = pathlib.Path({str(count_path)!r}) count = int(count_path.read_text(encoding="utf-8")) if count_path.exists() else 0 count_path.write_text(str(count + 1), encoding="utf-8") argv = sys.argv[1:] argv_path.write_text(json.dumps(argv), encoding="utf-8") if "--strict-mcp-config" in argv: print("error: unknown option '--strict-mcp-config'", file=sys.stderr) raise SystemExit(2) print(json.dumps({{"type": "result", "subtype": "success", "result": "{{\\"sentiment_score\\": 90}}"}})) """, ) preset = LocalCliPreset( preset_id="claude_code_cli", executable=sys.executable, argv=(script, *CLAUDE_CODE_CLI_PRESET.argv), display_name="Mock Claude Code CLI", extractor=CLAUDE_CODE_CLI_PRESET.extractor, contract_args=CLAUDE_CODE_CLI_PRESET.contract_args, ) backend = LocalCliGenerationBackend( _config(generation_backend="claude_code_cli"), preset=preset, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) runtime_argv = json.loads(argv_path.read_text(encoding="utf-8")) assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED assert exc_info.value.details["reason"] == "cli_contract_unsupported" assert "--strict-mcp-config" in runtime_argv assert count_path.read_text(encoding="utf-8") == "1" def test_non_zero_exit_mentions_preset_arg_without_unknown_marker_stays_generic(tmp_path: Path) -> None: preset = LocalCliPreset( "codex_cli", sys.executable, ( _script( tmp_path, """ import sys print("failed while writing --output-last-message file", file=sys.stderr) raise SystemExit(2) """, ), ), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.NON_ZERO_EXIT assert exc_info.value.details["reason"] == "non_zero_exit" def test_non_zero_exit_with_missing_last_message_still_maps_login_required(tmp_path: Path) -> None: preset = LocalCliPreset( "codex_cli", sys.executable, ( _script( tmp_path, """ import sys print("not authenticated, please login", file=sys.stderr) raise SystemExit(2) """, ), ), "Mock CLI", output_last_message_arg="--output-last-message", ) backend = LocalCliGenerationBackend(_config(), preset=preset) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.LOGIN_REQUIRED assert exc_info.value.details["reason"] == "login_required" def test_process_start_error_diagnostics_are_redacted(monkeypatch) -> None: home_path = Path.home() executable_path = str(home_path / "secret" / "bin" / "codex") monkeypatch.setattr("src.llm.local_cli_backend.shutil.which", lambda _cmd: executable_path) monkeypatch.setattr("src.llm.local_cli_backend.os.access", lambda _path, _mode: True) def _raise_os_error(*_args, **_kwargs): raise OSError(f"Exec format error: {executable_path} sk-secretsecretsecret") monkeypatch.setattr("src.llm.local_cli_backend.subprocess.Popen", _raise_os_error) backend = LocalCliGenerationBackend(_config()) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR error = exc_info.value.details["error"] assert str(home_path) not in error assert "sk-secret" not in error def test_prompt_is_passed_as_stdin_file_not_pipe(tmp_path: Path, monkeypatch) -> None: captured = {} def _raise_os_error(*_args, **kwargs): stdin = kwargs.get("stdin") captured["stdin"] = stdin captured["stdin_closed_at_popen"] = getattr(stdin, "closed", True) raise OSError("mock start failure") monkeypatch.setattr("src.llm.local_cli_backend.subprocess.Popen", _raise_os_error) backend = _backend(tmp_path, "print('unused')") with pytest.raises(GenerationError): backend.generate("x" * 200000, {}) stdin = captured["stdin"] assert stdin is not subprocess.PIPE assert hasattr(stdin, "fileno") assert not captured["stdin_closed_at_popen"] def test_timeout_kills_process_group(tmp_path: Path) -> None: pid_file = tmp_path / "child.pid" backend = _backend( tmp_path, f""" import subprocess, sys, time child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) open({str(pid_file)!r}, "w", encoding="utf-8").write(str(child.pid)) time.sleep(30) """, generation_backend_timeout_seconds=1, ) with pytest.raises(GenerationError) as exc_info: backend.generate("prompt", {}) assert exc_info.value.error_code is GenerationErrorCode.TIMEOUT child_pid = int(pid_file.read_text(encoding="utf-8")) deadline = time.time() + 3 while time.time() < deadline: try: os.kill(child_pid, 0) except OSError: break time.sleep(0.05) else: pytest.fail("child process was not terminated with the process group") def test_env_allowlist_and_denylist(monkeypatch) -> None: monkeypatch.setenv("PATH", "/bin") monkeypatch.setenv("HOME", "/tmp/home") monkeypatch.setenv("CODEX_HOME", "/tmp/codex-home") monkeypatch.setenv("LC_MESSAGES", "C") monkeypatch.setenv("UNRELATED_VALUE", "leak") monkeypatch.setenv("CODEX_CLI_TOKEN", "codex-secret") monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-secret") monkeypatch.setenv("ANTHROPIC_MODEL", "claude") monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/tmp/claude") monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", "{}") monkeypatch.setenv("OPENAI_API_KEY", "sk-secret") monkeypatch.setenv("WEBHOOK_TOKEN", "token") monkeypatch.setenv("AUTHORIZATION", "Bearer token") child_env = build_local_cli_env() assert child_env["PATH"] == "/bin" assert child_env["HOME"] == "/tmp/home" assert child_env["CODEX_HOME"] == "/tmp/codex-home" assert child_env["LC_MESSAGES"] == "C" assert "UNRELATED_VALUE" not in child_env assert "CODEX_CLI_TOKEN" not in child_env assert "ANTHROPIC_API_KEY" not in child_env assert "ANTHROPIC_MODEL" not in child_env assert "CLAUDE_CONFIG_DIR" not in child_env assert "OPENCODE_CONFIG_CONTENT" not in child_env assert "OPENAI_API_KEY" not in child_env assert "WEBHOOK_TOKEN" not in child_env assert "AUTHORIZATION" not in child_env def test_env_allowlist_preserves_windows_runtime_context() -> None: source = { "Path": r"C:\Users\tester\AppData\Local\Microsoft\WindowsApps", "SystemRoot": r"C:\Windows", "WINDIR": r"C:\Windows", "PATHEXT": ".COM;.EXE;.BAT;.CMD", "ComSpec": r"C:\Windows\System32\cmd.exe", "USERPROFILE": r"C:\Users\tester", "APPDATA": r"C:\Users\tester\AppData\Roaming", "LOCALAPPDATA": r"C:\Users\tester\AppData\Local", "HOMEDRIVE": "C:", "HOMEPATH": r"\Users\tester", "OPENAI_API_KEY": "sk-secret", "UNRELATED_VALUE": "leak", } child_env = build_local_cli_env(source) for key in ( "Path", "SystemRoot", "WINDIR", "PATHEXT", "ComSpec", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", ): assert child_env[key] == source[key] assert "APPDATA" not in child_env assert "LOCALAPPDATA" not in child_env assert "OPENAI_API_KEY" not in child_env assert "UNRELATED_VALUE" not in child_env def test_generate_passes_allowlisted_windows_context_to_child_env(monkeypatch, tmp_path: Path) -> None: windows_context = { "SystemRoot": r"C:\Windows", "WINDIR": r"C:\Windows", "PATHEXT": ".COM;.EXE;.BAT;.CMD", "ComSpec": r"C:\Windows\System32\cmd.exe", "USERPROFILE": r"C:\Users\tester", "HOMEDRIVE": "C:", "HOMEPATH": r"\Users\tester", } for key, value in windows_context.items(): monkeypatch.setenv(key, value) monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming") monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\tester\AppData\Local") monkeypatch.setenv("OPENAI_API_KEY", "sk-secret") monkeypatch.setenv("UNRELATED_VALUE", "leak") backend = _backend( tmp_path, """ import json, os keys = [ "SystemRoot", "WINDIR", "PATHEXT", "ComSpec", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "HOMEDRIVE", "HOMEPATH", "OPENAI_API_KEY", "UNRELATED_VALUE", ] print(json.dumps({key: os.environ.get(key) for key in keys}, ensure_ascii=False)) """, ) result = backend.generate("prompt", {}) payload = json.loads(result.text) for key, value in windows_context.items(): assert payload[key] == value assert payload["APPDATA"] is None assert payload["LOCALAPPDATA"] is None assert payload["OPENAI_API_KEY"] is None assert payload["UNRELATED_VALUE"] is None def test_popen_session_kwargs_are_platform_specific(monkeypatch) -> None: monkeypatch.setattr(local_cli_backend_module.os, "name", "nt") monkeypatch.setattr( local_cli_backend_module.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False, ) assert local_cli_backend_module._popen_session_kwargs() == { "creationflags": 0x00000200, } monkeypatch.setattr(local_cli_backend_module.os, "name", "posix") assert local_cli_backend_module._popen_session_kwargs() == { "start_new_session": True, } def test_windows_terminate_process_group_prefers_ctrl_break(monkeypatch) -> None: class FakeProcess: pid = 1234 def __init__(self) -> None: self.signals = [] self.terminated = False self.killed = False def poll(self): return None def send_signal(self, sig): self.signals.append(sig) def wait(self, timeout=None): return 0 def terminate(self): self.terminated = True def kill(self): self.killed = True monkeypatch.setattr(local_cli_backend_module.os, "name", "nt") monkeypatch.setattr( local_cli_backend_module.signal, "CTRL_BREAK_EVENT", 1, raising=False, ) process = FakeProcess() LocalCliGenerationBackend._terminate_process_group(process) assert process.signals == [1] assert process.terminated is False assert process.killed is False def test_windows_terminate_process_group_falls_back_to_kill(monkeypatch) -> None: class FakeProcess: pid = 1234 def __init__(self) -> None: self.signals = [] self.terminated = False self.killed = False self._wait_calls = 0 def poll(self): return None def send_signal(self, sig): self.signals.append(sig) raise OSError("no console") def wait(self, timeout=None): self._wait_calls += 1 if self._wait_calls == 1: raise subprocess.TimeoutExpired(cmd="mock", timeout=timeout) return 0 def terminate(self): self.terminated = True def kill(self): self.killed = True monkeypatch.setattr(local_cli_backend_module.os, "name", "nt") monkeypatch.setattr( local_cli_backend_module.signal, "CTRL_BREAK_EVENT", 1, raising=False, ) process = FakeProcess() LocalCliGenerationBackend._terminate_process_group(process) assert process.signals == [1] assert process.terminated is True assert process.killed is True def test_diagnostics_redaction_and_truncation() -> None: text = ( "Authorization: Bearer sk-abc123456789012345678901234567890 " "https://user:pass@example.com/path " + "safe text " * 20 ) redacted = redact_diagnostic_text(text, home="/Users/example", limit=60) assert "sk-abc" not in redacted assert "user:pass" not in redacted assert "" in redacted def test_diagnostics_redacts_webhook_urls_and_preserves_adjacent_normal_urls() -> None: text = ( "slack=https://hooks.slack.com/services/T000/B000/super-secret " "dingtalk=https://oapi.dingtalk.com/robot/send?access_token=abc123&foo=bar " "docs=https://example.com/public/docs?foo=bar" ) redacted = redact_diagnostic_text(text, limit=1000) assert "hooks.slack.com" not in redacted assert "oapi.dingtalk.com" not in redacted assert "super-secret" not in redacted assert "access_token" not in redacted assert redacted.count("") == 2 assert "https://example.com/public/docs?foo=bar" in redacted def test_effective_local_cli_concurrency_uses_minimum() -> None: assert effective_local_cli_concurrency(_config()) == 1 assert effective_local_cli_concurrency( _config(generation_backend_max_concurrency=4, local_cli_backend_max_concurrency=2) ) == 2 assert effective_local_cli_concurrency( _config(generation_backend_max_concurrency=1, local_cli_backend_max_concurrency=5) ) == 1 assert effective_local_cli_concurrency( _config(generation_backend_max_concurrency=999, local_cli_backend_max_concurrency=999) ) == 4 def test_local_cli_concurrency_limit_serializes_subprocesses(tmp_path: Path) -> None: events_dir = tmp_path / "events" events_dir.mkdir() backend = _backend( tmp_path, f""" import json, os, pathlib, time events_dir = pathlib.Path({str(events_dir)!r}) pid = os.getpid() start = time.time() time.sleep(0.25) end = time.time() (events_dir / f"{{pid}}.json").write_text( json.dumps({{"start": start, "end": end}}), encoding="utf-8", ) print(json.dumps({{"sentiment_score": 60}})) """, generation_backend_max_concurrency=4, local_cli_backend_max_concurrency=1, ) with ThreadPoolExecutor(max_workers=2) as executor: results = list(executor.map(lambda _: backend.generate("prompt", {}), range(2))) assert [json.loads(result.text)["sentiment_score"] for result in results] == [60, 60] intervals = [ json.loads(path.read_text(encoding="utf-8")) for path in events_dir.glob("*.json") ] assert len(intervals) == 2 intervals.sort(key=lambda item: item["start"]) assert intervals[1]["start"] >= intervals[0]["end"]