From f8edf343eb60e7bc461d0dba76d145d04c8a1d0e Mon Sep 17 00:00:00 2001 From: QUSETIONS <184124238+QUSETIONS@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:22:35 +0800 Subject: [PATCH 1/2] Harden runtime model selection and release checks --- benchmarks/release_readiness.py | 16 ++ benchmarks/release_readiness_results.json | 92 ++++----- benchmarks/release_readiness_results.md | 24 +-- benchmarks/runtime_profile_eval_results.json | 22 +-- benchmarks/runtime_profile_eval_results.md | 14 +- minicode/agent_loop.py | 8 +- minicode/config.py | 111 ++++++++--- minicode/headless.py | 1 + minicode/main.py | 1 + minicode/mcp.py | 12 +- minicode/model_registry.py | 70 ++++++- minicode/model_switcher.py | 9 +- minicode/product_surfaces.py | 65 +++++-- minicode/tui/input_handler.py | 1 + tests/test_agent_loop.py | 12 +- tests/test_config.py | 78 +++++++- tests/test_functional_completeness.py | 6 +- tests/test_headless.py | 3 + tests/test_mcp.py | 12 ++ tests/test_memory_integration.py | 32 ++++ tests/test_model_selection_controller.py | 9 + tests/test_model_switching.py | 21 +++ tests/test_product_surfaces.py | 40 ++++ tests/test_release_integration.py | 185 +++++++++++++++++++ tests/test_release_readiness_benchmark.py | 36 ++++ tests/test_tty_app.py | 96 +++++++++- 26 files changed, 848 insertions(+), 128 deletions(-) diff --git a/benchmarks/release_readiness.py b/benchmarks/release_readiness.py index b102be2..bfb65a9 100644 --- a/benchmarks/release_readiness.py +++ b/benchmarks/release_readiness.py @@ -50,6 +50,20 @@ BENCHMARKS_DIR = REPO_ROOT / "benchmarks" +def _clear_test_provider_environment(env: dict[str, str]) -> None: + """Keep the release test suite hermetic while live smoke uses real config.""" + for key in list(env): + if key.startswith(( + "MINI_CODE_", + "OPENAI_", + "ANTHROPIC_", + "OPENROUTER_", + "CUSTOM_", + "DEEPSEEK_", + )): + env.pop(key, None) + + def _normalize_evidence_paths( value: object, *, @@ -61,6 +75,8 @@ def _normalize_evidence_paths( def _run_command(label: str, command: list[str], *, cwd: Path, timeout: int = 1800) -> ReleaseCheck: env = dict(os.environ) + if label == "pytest-q": + _clear_test_provider_environment(env) existing_pythonpath = env.get("PYTHONPATH", "") env["PYTHONPATH"] = ( str(REPO_ROOT) diff --git a/benchmarks/release_readiness_results.json b/benchmarks/release_readiness_results.json index 770065a..40a668b 100644 --- a/benchmarks/release_readiness_results.json +++ b/benchmarks/release_readiness_results.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-07-10T04:23:46.564243+00:00", + "generated_at": "2026-07-17T06:54:02.128764+00:00", "status": "blocked", "local_gate_status": "blocked", "provider_status": "at-risk", @@ -10,7 +10,7 @@ ], "compile_check": { "label": "compileall", - "command": "/usr/bin/python3 -m compileall -q minicode tests benchmarks Main Package", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m compileall -q minicode tests benchmarks Main Package", "exit_code": 0, "status": "passed", "summary": "compileall completed.", @@ -19,16 +19,16 @@ }, "test_check": { "label": "pytest-q", - "command": "/usr/bin/python3 -m pytest -q", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m pytest -q", "exit_code": 0, "status": "passed", - "summary": "1297 passed, 2 skipped in 106.00s (0:01:46)", - "stdout": "........................................................................ [ 5%]\n........................................................................ [ 11%]\n........................................................................ [ 16%]\n........................................................................ [ 22%]\n........................................................................ [ 27%]\n........................................................................ [ 33%]\n........................................................................ [ 38%]\n........................................................................ [ 44%]\n........................................................................ [ 49%]\n........ss.............................................................. [ 55%]\n........................................................................ [ 60%]\n........................................................................ [ 66%]\n........................................................................ [ 72%]\n........................................................................ [ 77%]\n........................................................................ [ 83%]\n........................................................................ [ 88%]\n........................................................................ [ 94%]\n........................................................................ [ 99%]\n... [100%]\n1297 passed, 2 skipped in 106.00s (0:01:46)", + "summary": "1310 passed, 2 skipped in 27.20s", + "stdout": "........................................................................ [ 5%]\n........................................................................ [ 10%]\n........................................................................ [ 16%]\n........................................................................ [ 21%]\n........................................................................ [ 27%]\n........................................................................ [ 32%]\n........................................................................ [ 38%]\n........................................................................ [ 43%]\n........................................................................ [ 49%]\n............ss.......................................................... [ 54%]\n........................................................................ [ 60%]\n........................................................................ [ 65%]\n........................................................................ [ 71%]\n........................................................................ [ 76%]\n........................................................................ [ 82%]\n........................................................................ [ 87%]\n........................................................................ [ 93%]\n........................................................................ [ 98%]\n................ [100%]\n1310 passed, 2 skipped in 27.20s", "stderr": "" }, "runtime_eval_check": { "label": "runtime-profile-eval", - "command": "/usr/bin/python3 benchmarks/runtime_profile_eval.py", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 benchmarks/runtime_profile_eval.py", "exit_code": 0, "status": "passed", "summary": "benchmarks/runtime_profile_eval_results.md", @@ -37,53 +37,53 @@ }, "structure_check": { "label": "structure-compliance", - "command": "/usr/bin/python3 -m minicode.structure_check --root . --hotspots 5 --max-dependency-upstream 4 --check-material-inventory --report .temp/structure-compliance.json", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.structure_check --root . --hotspots 5 --max-dependency-upstream 4 --check-material-inventory --report .temp/structure-compliance.json", "exit_code": 0, "status": "passed", "summary": "quality gate findings: 0", - "stdout": "AGENTS structure compliance: passed\nrecords: 81\nstructure findings: 0\ndependency edges: 5\ndependency impact nodes: 7\ndependency impact hotspots: 2\n - 4 direct upstream: Main/MinicodeFrontline/Src/Application/Dto/AppProjection.py\n - 1 direct upstream: Package/EngineeringStructure/Src/Application/Query/ProductRootProjection.py\nmax dependency direct upstream: 4\nimport dependency edges: 0\nsame-project import dependency edges: 0\nvendor import dependency edges: 0\ncross-project import dependency edges: 0\nimport impact nodes: 0\nimport impact hotspots: 0\nmax import transitive upstream: 0\nimport findings: 0\ndependency findings: 0\ntotal findings: 0\nmaterial inventory gates: 19\nmaterial inventory materials: 9\nmaterial inventory findings: 0\nquality gate findings: 0", + "stdout": "AGENTS structure compliance: passed\nrecords: 91\nstructure findings: 0\ndependency edges: 5\ndependency impact nodes: 7\ndependency impact hotspots: 2\n - 4 direct upstream: Main/MinicodeFrontline/Src/Application/Dto/AppProjection.py\n - 1 direct upstream: Package/EngineeringStructure/Src/Application/Query/ProductRootProjection.py\nmax dependency direct upstream: 4\nimport dependency edges: 0\nsame-project import dependency edges: 0\nvendor import dependency edges: 0\ncross-project import dependency edges: 0\nimport impact nodes: 0\nimport impact hotspots: 0\nmax import transitive upstream: 0\nimport findings: 0\ndependency findings: 0\ntotal findings: 0\nmaterial inventory gates: 19\nmaterial inventory materials: 9\nmaterial inventory findings: 0\nquality gate findings: 0", "stderr": "" }, "smoke_checks": [ { "label": "list-workspace-sessions", - "command": "/usr/bin/python3 -m minicode.main --list-workspace-sessions", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.main --list-workspace-sessions", "exit_code": 0, "status": "passed", "summary": "Total: 1 session(s)", - "stdout": "Saved sessions:\n\n 1. [3deaf1c7] 2026-07-10 04:23 - outputs/release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\nTotal: 1 session(s)", + "stdout": "Saved sessions:\n\n 1. [ee9037a5] 2026-07-17 06:54 - outputs/release_smoke_workspace\n Messages: 0 | First: (empty)\n Checkpoints: 1\n Runtime: phase:verify@2\n\nTotal: 1 session(s)", "stderr": "" }, { "label": "inspect-session", - "command": "/usr/bin/python3 -m minicode.main --inspect-session latest", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.main --inspect-session latest", "exit_code": 0, "status": "passed", "summary": "- [tool:edit_file/success] Patched demo.txt", - "stdout": "Session inspect: 3deaf1c7\n Created: 2026-07-10 04:23:46\n Updated: 2026-07-10 04:23:46\n Workspace: outputs/release_smoke_workspace\n Messages: 0\n Transcript entries: 2\n History entries: 1\n Skills: (none)\n MCP servers: (none)\n Checkpoints: 1\n Runtime: phase:verify@2\n Readiness: ready via anthropic-compatible\n Instructions: 1 layer(s): project-managed\n Hooks: 1/1 hook(s) enabled\n Delegation: 1 running, 3 slot(s) open\n Extensions: 1 extension(s): git-helpers\n\nRecent checkpoints: 1 saved; latest [b2fa7806] demo.txt\n\nInstruction layers:\n - project-managed [project/managed, present] Prefer verification-first delivery.\n\nHook surface:\n 1/1 hook(s) enabled\n\nDelegation surface:\n 1 running, 3 slot(s) open\n - lint-worker :: running\n\nExtensions:\n - git-helpers [project] 1.0.0, enabled :: Local helper bundle\n\nReadiness:\n ready via anthropic-compatible (ready)\n\nRecent transcript (2 shown):\n - [runtime:phase] Runtime phase: verify.\n - [tool:edit_file/success] Patched demo.txt", + "stdout": "Session inspect: ee9037a5\n Created: 2026-07-17 06:54:02\n Updated: 2026-07-17 06:54:02\n Workspace: outputs/release_smoke_workspace\n Messages: 0\n Transcript entries: 2\n History entries: 1\n Skills: (none)\n MCP servers: (none)\n Checkpoints: 1\n Runtime: phase:verify@2\n Readiness: ready via anthropic-compatible\n Instructions: 1 layer(s): project-managed\n Hooks: 1/1 hook(s) enabled\n Delegation: 1 running, 3 slot(s) open\n Extensions: 1 extension(s): git-helpers\n\nRecent checkpoints: 1 saved; latest [55f27fe1] demo.txt\n\nInstruction layers:\n - project-managed [project/managed, present] Prefer verification-first delivery.\n\nHook surface:\n 1/1 hook(s) enabled\n\nDelegation surface:\n 1 running, 3 slot(s) open\n - lint-worker :: running\n\nExtensions:\n - git-helpers [project] 1.0.0, enabled :: Local helper bundle\n\nReadiness:\n ready via anthropic-compatible (ready)\n\nRecent transcript (2 shown):\n - [runtime:phase] Runtime phase: verify.\n - [tool:edit_file/success] Patched demo.txt", "stderr": "" }, { "label": "replay-session", - "command": "/usr/bin/python3 -m minicode.main --replay-session latest", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.main --replay-session latest", "exit_code": 0, "status": "passed", "summary": "- [tool:edit_file/success] Patched demo.txt", - "stdout": "Session replay: 3deaf1c7\n Workspace: outputs/release_smoke_workspace\n Created: 2026-07-10 04:23:46\n Updated: 2026-07-10 04:23:46\n Runtime: phase:verify@2\n Checkpoints: 1\n Readiness: ready via anthropic-compatible\n Delegation: 1 running, 3 slot(s) open\n\nCheckpoint trail (1 shown):\n - [b2fa7806] 2026-07-10 04:23:46 :: demo.txt (edit)\n\nInstruction layers:\n - project-managed [project/managed, present] Prefer verification-first delivery.\n\nExtensions:\n - git-helpers [project] 1.0.0, enabled :: Local helper bundle\n\nPrompt history (1 shown):\n 1. continue with runtime trace\n\nTranscript timeline (2 shown):\n - [runtime:phase] Runtime phase: verify.\n - [tool:edit_file/success] Patched demo.txt", + "stdout": "Session replay: ee9037a5\n Workspace: outputs/release_smoke_workspace\n Created: 2026-07-17 06:54:02\n Updated: 2026-07-17 06:54:02\n Runtime: phase:verify@2\n Checkpoints: 1\n Readiness: ready via anthropic-compatible\n Delegation: 1 running, 3 slot(s) open\n\nCheckpoint trail (1 shown):\n - [55f27fe1] 2026-07-17 06:54:02 :: demo.txt (edit)\n\nInstruction layers:\n - project-managed [project/managed, present] Prefer verification-first delivery.\n\nExtensions:\n - git-helpers [project] 1.0.0, enabled :: Local helper bundle\n\nPrompt history (1 shown):\n 1. continue with runtime trace\n\nTranscript timeline (2 shown):\n - [runtime:phase] Runtime phase: verify.\n - [tool:edit_file/success] Patched demo.txt", "stderr": "" }, { "label": "preview-rewind", - "command": "/usr/bin/python3 -m minicode.main --preview-rewind latest", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.main --preview-rewind latest", "exit_code": 0, "status": "passed", "summary": "Type: edit", - "stdout": "Rewind preview for session 3deaf1c7:\n\nWould restore 1 checkpoint(s) across 1 file(s).\nMode: restore pre-edit file snapshots.\n\nPlanned restores:\n 1. [b2fa7806] 2026-07-10 04:23:46 - outputs/release_smoke_workspace/demo.txt\n Restores: existing file\n Type: edit", + "stdout": "Rewind preview for session ee9037a5:\n\nWould restore 1 checkpoint(s) across 1 file(s).\nMode: restore pre-edit file snapshots.\n\nPlanned restores:\n 1. [55f27fe1] 2026-07-17 06:54:02 - outputs/release_smoke_workspace/demo.txt\n Restores: existing file\n Type: edit", "stderr": "" }, { "label": "readiness-cli", - "command": "/usr/bin/python3 -m minicode.main --readiness", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.main --readiness", "exit_code": 0, "status": "passed", "summary": "readiness: blocked (unknown) [No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.]", @@ -92,7 +92,7 @@ }, { "label": "readiness-json", - "command": "/usr/bin/python3 -m minicode.main --readiness-json", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.main --readiness-json", "exit_code": 0, "status": "passed", "summary": "readiness blocked (provider-config)", @@ -101,7 +101,7 @@ }, { "label": "readiness-script-json", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --json", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --json", "exit_code": 0, "status": "passed", "summary": "readiness blocked (provider-config)", @@ -110,7 +110,7 @@ }, { "label": "readiness-threshold", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --json --fail-on blocked", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --json --fail-on blocked", "exit_code": 1, "status": "failed", "summary": "readiness blocked (provider-config)", @@ -119,7 +119,7 @@ }, { "label": "readiness-examples", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --examples --examples-out .temp/readiness-fallback-examples.json --fail-on blocked", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --examples --examples-out .temp/readiness-fallback-examples.json --fail-on blocked", "exit_code": 1, "status": "failed", "summary": "fallback examples 2 (provider-config)", @@ -128,7 +128,7 @@ }, { "label": "readiness-doctor", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --doctor --doctor-out .temp/readiness-doctor.md --fail-on blocked", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --doctor --doctor-out .temp/readiness-doctor.md --fail-on blocked", "exit_code": 1, "status": "failed", "summary": "doctor blocked (provider-config)", @@ -137,7 +137,7 @@ }, { "label": "readiness-repair-plan", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --repair-plan --repair-plan-out .temp/readiness-repair-plan.json --fail-on blocked", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --repair-plan --repair-plan-out .temp/readiness-repair-plan.json --fail-on blocked", "exit_code": 1, "status": "failed", "summary": "repair plan 6 (provider-config)", @@ -146,7 +146,7 @@ }, { "label": "readiness-patch-preview", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --patch-preview --patch-preview-out .temp/readiness-fallback-patch-preview.json --fail-on blocked", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --patch-preview --patch-preview-out .temp/readiness-fallback-patch-preview.json --fail-on blocked", "exit_code": 1, "status": "failed", "summary": "patch preview 2 (provider-config)", @@ -155,7 +155,7 @@ }, { "label": "readiness-bundle-generate", - "command": "/usr/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --bundle-out .temp/readiness-bundle --fail-on blocked", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.readiness --cwd outputs/release_smoke_workspace --bundle-out .temp/readiness-bundle --fail-on blocked", "exit_code": 1, "status": "failed", "summary": "- repair_plan_json: .temp/readiness-bundle/readiness-repair-plan.json", @@ -257,11 +257,11 @@ { "label": "headless-smoke", "outcome": "provider_channel_unavailable", - "command": "/usr/bin/python3 -m minicode.headless Reply with exactly OK.", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.headless Reply with exactly OK.", "exit_code": 1, - "summary": "2026-07-10 12:25:41,644 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).", + "summary": "2026-07-17 14:57:56,583 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).", "stdout": "", - "stderr": "2026-07-10 12:25:41,644 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\n2026-07-10 12:25:41,645 [ERROR] minicode.headless: Config load failed: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\nTraceback (most recent call last):\n File \"minicode/headless.py\", line 178, in run_headless\n runtime = load_runtime_config(cwd)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"minicode/config.py\", line 560, in load_runtime_config\n raise RuntimeError(\"No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\")\nRuntimeError: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n2026-07-10 12:25:41,648 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\nConfig error: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n", + "stderr": "2026-07-17 14:57:56,583 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\n2026-07-17 14:57:56,583 [ERROR] minicode.headless: Config load failed: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\nTraceback (most recent call last):\n File \"minicode/headless.py\", line 178, in run_headless\n runtime = load_runtime_config(cwd)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"minicode/config.py\", line 608, in load_runtime_config\n raise RuntimeError(\"No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\")\nRuntimeError: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n2026-07-17 14:57:56,585 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\nConfig error: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n", "risk_scope": "provider-config", "error_code": "", "request_id": "", @@ -298,22 +298,22 @@ "label": "bundle_manifest_json", "path": ".temp/readiness-bundle/readiness-artifact-manifest.json", "exists": true, - "size_bytes": 1526, - "sha256": "16da44b19a63c285833f903df5077274aedf89237e162f69a4178f2427cf45a3" + "size_bytes": 1346, + "sha256": "e1756d9f9d9747e2cc36fe19cf5f870a36891f418b52b7411cf240b1dcd97dae" }, { "label": "doctor_markdown", "path": ".temp/readiness-doctor.md", "exists": true, - "size_bytes": 3307, - "sha256": "66b1462dca5011b5cc353aa1f901876f735c27fdd125dbeba52138f077a1fe9e" + "size_bytes": 3277, + "sha256": "1cee8e1f80c8d2a267a20297aaca4e4c98fa0e517b17a32d70daf58ed2bb6eaa" }, { "label": "fallback_examples_json", "path": ".temp/readiness-fallback-examples.json", "exists": true, - "size_bytes": 876, - "sha256": "ceee505c6b4d879a2ba3bb20b349fb15d969ba8b2088db3fda95952bd3d3e6dd" + "size_bytes": 882, + "sha256": "f3664ba7de4a53f32be1ca42223f3a5096d0ee27f234e4c6a308d65f2e7427fe" }, { "label": "fallback_simulations_json", @@ -326,43 +326,43 @@ "label": "headless_trace", "path": ".temp/headless-provider-smoke-trace.json", "exists": true, - "size_bytes": 7947, - "sha256": "642d9adad0bcaa3029df2180d7176ca62947b437acc2a1b19dfc858cfe5f9ccc" + "size_bytes": 7935, + "sha256": "1bfddc2dd3f9ed4b5f499a14c2268b65de4e15efee6a7dfcf3e09fcf3cd61531" }, { "label": "json", "path": "benchmarks/runtime_profile_eval_results.json", "exists": true, - "size_bytes": 6227, - "sha256": "bc160c17a80ffec90b19d00680be7379a2f337dc3aebe461ed7d87d356e595b9" + "size_bytes": 6289, + "sha256": "12acb423538923629e186a507136d631a7e50e4d52ffeae044ed79c1157041f6" }, { "label": "markdown", "path": "benchmarks/runtime_profile_eval_results.md", "exists": true, - "size_bytes": 2374, - "sha256": "4b88fb8c3795d9c5cbba56d1eebe47e774c4c380d00509949d59ff90adb7d6cd" + "size_bytes": 2373, + "sha256": "5a41364c9693f42d20c4009a9ad10920883f23ad5ced8abadd5416d8b68f2121" }, { "label": "patch_preview_json", "path": ".temp/readiness-fallback-patch-preview.json", "exists": true, - "size_bytes": 1611, - "sha256": "d3f81131954a13655ac3f46452a334b0268504e537f001b68777f9bb3d53e60f" + "size_bytes": 1617, + "sha256": "87f7c97f4566dcf156e3dc1660992852fd22f3b295fe300c2c63c082aea96a0c" }, { "label": "repair_plan_json", "path": ".temp/readiness-repair-plan.json", "exists": true, - "size_bytes": 2473, - "sha256": "d037b485452959d8af55e2f9b69599d2ebba8dbe04bc12b01841222cf2afca71" + "size_bytes": 2482, + "sha256": "5d9d0551e73ca6abe1f027b41de4461995c9ab4089014250fa97163eb9ce28a6" }, { "label": "structure_compliance", "path": ".temp/structure-compliance.json", "exists": true, - "size_bytes": 89486, - "sha256": "ae1d66d73debe0588536f4d3d1d4c5fb45d763a37b7c07094e87e4fd87e47849" + "size_bytes": 98320, + "sha256": "a0d95a4c996e1a71b6c3da740ba96ff660f6a3502addfcf5894ad02ac5633cea" } ], "readiness_report": { diff --git a/benchmarks/release_readiness_results.md b/benchmarks/release_readiness_results.md index 1a244e7..f6a2675 100644 --- a/benchmarks/release_readiness_results.md +++ b/benchmarks/release_readiness_results.md @@ -1,6 +1,6 @@ # MiniCode Release Readiness -- Generated at: 2026-07-10T04:23:46.564243+00:00 +- Generated at: 2026-07-17T06:54:02.128764+00:00 - Status: blocked - Local gates: blocked - Provider status: at-risk @@ -16,7 +16,7 @@ | check | status | exit_code | summary | | --- | --- | ---: | --- | | compileall | passed | 0 | compileall completed. | -| pytest-q | passed | 0 | 1297 passed, 2 skipped in 106.00s (0:01:46) | +| pytest-q | passed | 0 | 1310 passed, 2 skipped in 27.20s | | runtime-profile-eval | passed | 0 | benchmarks/runtime_profile_eval_results.md | | structure-compliance | passed | 0 | quality gate findings: 0 | @@ -52,7 +52,7 @@ | label | outcome | failure_category | retryable | ownership | recovery_action | risk_scope | error_code | request_id | exit_code | summary | | --- | --- | --- | --- | --- | --- | --- | --- | --- | ---: | --- | -| headless-smoke | provider_channel_unavailable | configuration | false | local-configuration | Repair model-to-provider channel configuration. | provider-config | - | - | 1 | 2026-07-10 12:25:41,644 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1). | +| headless-smoke | provider_channel_unavailable | configuration | false | local-configuration | Repair model-to-provider channel configuration. | provider-config | - | - | 1 | 2026-07-17 14:57:56,583 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1). | ## Provider Action Items @@ -119,13 +119,13 @@ | label | exists | size_bytes | sha256 | path | | --- | --- | ---: | --- | --- | -| bundle_manifest_json | yes | 1526 | 16da44b19a63 | .temp/readiness-bundle/readiness-artifact-manifest.json | -| doctor_markdown | yes | 3307 | 66b1462dca50 | .temp/readiness-doctor.md | -| fallback_examples_json | yes | 876 | ceee505c6b4d | .temp/readiness-fallback-examples.json | +| bundle_manifest_json | yes | 1346 | e1756d9f9d97 | .temp/readiness-bundle/readiness-artifact-manifest.json | +| doctor_markdown | yes | 3277 | 1cee8e1f80c8 | .temp/readiness-doctor.md | +| fallback_examples_json | yes | 882 | f3664ba7de4a | .temp/readiness-fallback-examples.json | | fallback_simulations_json | yes | 875 | 1355d9236755 | .temp/readiness-bundle/readiness-fallback-simulations.json | -| headless_trace | yes | 7947 | 642d9adad0bc | .temp/headless-provider-smoke-trace.json | -| json | yes | 6227 | bc160c17a80f | benchmarks/runtime_profile_eval_results.json | -| markdown | yes | 2374 | 4b88fb8c3795 | benchmarks/runtime_profile_eval_results.md | -| patch_preview_json | yes | 1611 | d3f81131954a | .temp/readiness-fallback-patch-preview.json | -| repair_plan_json | yes | 2473 | d037b4854529 | .temp/readiness-repair-plan.json | -| structure_compliance | yes | 89486 | ae1d66d73deb | .temp/structure-compliance.json | \ No newline at end of file +| headless_trace | yes | 7935 | 1bfddc2dd3f9 | .temp/headless-provider-smoke-trace.json | +| json | yes | 6289 | 12acb4235389 | benchmarks/runtime_profile_eval_results.json | +| markdown | yes | 2373 | 5a41364c9693 | benchmarks/runtime_profile_eval_results.md | +| patch_preview_json | yes | 1617 | 87f7c97f4566 | .temp/readiness-fallback-patch-preview.json | +| repair_plan_json | yes | 2482 | 5d9d0551e73c | .temp/readiness-repair-plan.json | +| structure_compliance | yes | 98320 | a0d95a4c996e | .temp/structure-compliance.json | \ No newline at end of file diff --git a/benchmarks/runtime_profile_eval_results.json b/benchmarks/runtime_profile_eval_results.json index 682172d..9189aab 100644 --- a/benchmarks/runtime_profile_eval_results.json +++ b/benchmarks/runtime_profile_eval_results.json @@ -4,7 +4,7 @@ "scenario": "depth-budget-floor", "condition": "single", "runtime_profile": "single", - "wall_time_ms": 12.948627001605928, + "wall_time_ms": 4.455542002688162, "model_calls": 1, "tool_starts": 0, "tool_results": 0, @@ -29,7 +29,7 @@ "scenario": "depth-budget-floor", "condition": "single-deep", "runtime_profile": "single-deep", - "wall_time_ms": 2.634717006003484, + "wall_time_ms": 0.971665998804383, "model_calls": 2, "tool_starts": 0, "tool_results": 0, @@ -54,7 +54,7 @@ "scenario": "widening-escalation", "condition": "single", "runtime_profile": "single", - "wall_time_ms": 2.0254789997125044, + "wall_time_ms": 0.6387499888660386, "model_calls": 1, "tool_starts": 0, "tool_results": 0, @@ -79,7 +79,7 @@ "scenario": "widening-escalation", "condition": "single-deep", "runtime_profile": "single-deep", - "wall_time_ms": 4.325524998421315, + "wall_time_ms": 1.3504160015145317, "model_calls": 10, "tool_starts": 0, "tool_results": 0, @@ -117,7 +117,7 @@ "total_tool_starts": 0, "total_tool_results": 0, "total_runtime_events": 4, - "total_wall_time_ms": 14.974106001318432, + "total_wall_time_ms": 5.094291991554201, "completion_rate": 0.0, "widened_rate": 0.0, "verification_guard_rate": 0.0, @@ -125,7 +125,7 @@ "avg_tool_starts": 0.0, "avg_tool_results": 0.0, "avg_runtime_events": 2.0, - "avg_wall_time_ms": 7.487053000659216 + "avg_wall_time_ms": 2.5471459957771003 }, "single-deep": { "runs": 2, @@ -136,7 +136,7 @@ "total_tool_starts": 0, "total_tool_results": 0, "total_runtime_events": 9, - "total_wall_time_ms": 6.960242004424799, + "total_wall_time_ms": 2.3220820003189147, "completion_rate": 1.0, "widened_rate": 0.5, "verification_guard_rate": 0.0, @@ -144,18 +144,18 @@ "avg_tool_starts": 0.0, "avg_tool_results": 0.0, "avg_runtime_events": 4.5, - "avg_wall_time_ms": 3.4801210022123996 + "avg_wall_time_ms": 1.1610410001594573 } }, "provider_diagnostics": [ { "label": "headless-smoke", "outcome": "provider_channel_unavailable", - "command": "/usr/bin/python3 -m minicode.headless Reply with exactly OK.", + "command": "~/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 -m minicode.headless Reply with exactly OK.", "exit_code": 1, - "summary": "2026-07-10 12:25:41,644 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).", + "summary": "2026-07-17 14:57:56,583 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).", "stdout": "", - "stderr": "2026-07-10 12:25:41,644 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\n2026-07-10 12:25:41,645 [ERROR] minicode.headless: Config load failed: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\nTraceback (most recent call last):\n File \"minicode/headless.py\", line 178, in run_headless\n runtime = load_runtime_config(cwd)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"minicode/config.py\", line 560, in load_runtime_config\n raise RuntimeError(\"No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\")\nRuntimeError: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n2026-07-10 12:25:41,648 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\nConfig error: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n", + "stderr": "2026-07-17 14:57:56,583 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\n2026-07-17 14:57:56,583 [ERROR] minicode.headless: Config load failed: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\nTraceback (most recent call last):\n File \"minicode/headless.py\", line 178, in run_headless\n runtime = load_runtime_config(cwd)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"minicode/config.py\", line 608, in load_runtime_config\n raise RuntimeError(\"No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\")\nRuntimeError: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n2026-07-17 14:57:56,585 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --trust-project-mcp or MINI_CODE_TRUST_PROJECT_MCP=1).\nConfig error: No model configured. Set ~/.mini-code/settings.json or ANTHROPIC_MODEL.\n", "risk_scope": "provider-config", "error_code": "", "request_id": "", diff --git a/benchmarks/runtime_profile_eval_results.md b/benchmarks/runtime_profile_eval_results.md index 180e3a7..2fa92e2 100644 --- a/benchmarks/runtime_profile_eval_results.md +++ b/benchmarks/runtime_profile_eval_results.md @@ -4,17 +4,17 @@ | condition | runs | completion_rate | widened_rate | verification_guard_rate | avg_model_calls | avg_runtime_events | avg_wall_time_ms | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| single | 2 | 0.00 | 0.00 | 0.00 | 1.00 | 2.00 | 7.49 | -| single-deep | 2 | 1.00 | 0.50 | 0.00 | 6.00 | 4.50 | 3.48 | +| single | 2 | 0.00 | 0.00 | 0.00 | 1.00 | 2.00 | 2.55 | +| single-deep | 2 | 1.00 | 0.50 | 0.00 | 6.00 | 4.50 | 1.16 | ## Scenario Rows | scenario | condition | completed | stop_reason | widened | verification_guard | runtime_events | model_calls | wall_time_ms | final_message | | --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | --- | -| depth-budget-floor | single | no | max_steps | no | no | 2 | 1 | 12.95 | Reached the maximum tool step limit for this turn. | -| depth-budget-floor | single-deep | yes | done | no | no | 2 | 2 | 2.63 | done | -| widening-escalation | single | no | max_steps | no | no | 2 | 1 | 2.03 | Reached the maximum tool step limit for this turn. | -| widening-escalation | single-deep | yes | done | yes | no | 7 | 10 | 4.33 | done with a broader plan | +| depth-budget-floor | single | no | max_steps | no | no | 2 | 1 | 4.46 | Reached the maximum tool step limit for this turn. | +| depth-budget-floor | single-deep | yes | done | no | no | 2 | 2 | 0.97 | done | +| widening-escalation | single | no | max_steps | no | no | 2 | 1 | 0.64 | Reached the maximum tool step limit for this turn. | +| widening-escalation | single-deep | yes | done | yes | no | 7 | 10 | 1.35 | done with a broader plan | ## Runtime Timelines @@ -27,7 +27,7 @@ | label | outcome | category | retryable | ownership | recovery_action | risk_scope | readiness | repair_steps | trace | error_code | request_id | exit_code | summary | | --- | --- | --- | --- | --- | --- | --- | --- | ---: | --- | --- | --- | ---: | --- | -| headless-smoke | provider_channel_unavailable | configuration | no | local-configuration | Repair model-to-provider channel configuration. | provider-config | blocked | 6 | .temp/headless-provider-smoke-trace.json | - | - | 1 | 2026-07-10 12:25:41,644 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --... | +| headless-smoke | provider_channel_unavailable | configuration | no | local-configuration | Repair model-to-provider channel configuration. | provider-config | blocked | 6 | .temp/headless-provider-smoke-trace.json | - | - | 1 | 2026-07-17 14:57:56,583 [WARNING] minicode.config: Project .mcp.json found at .mcp.json but NOT loaded (security: use --... | Guidance for `headless-smoke`: - Verify the selected model group and provider channel configuration. diff --git a/minicode/agent_loop.py b/minicode/agent_loop.py index a5f63af..7eca03b 100644 --- a/minicode/agent_loop.py +++ b/minicode/agent_loop.py @@ -751,6 +751,7 @@ def run_agent_turn( on_assistant_stream_chunk: Callable[[str], None] | None = None, on_thinking_chunk: Callable[[str], None] | None = None, context_manager: ContextManager | None = None, + memory_manager: MemoryManager | None = None, runtime: dict | None = None, metrics_collector: AgentMetricsCollector | None = None, system_prompt: str = "", @@ -955,7 +956,11 @@ def emit_runtime_event( circuit_breaker_limit=3, session_memory_enabled=True, ) - memory_mgr = MemoryManager(project_root=cwd) + memory_mgr = ( + memory_manager + if memory_manager is not None + else MemoryManager(project_root=cwd) + ) # 将 memory_mgr 注入 ReflectionEngine,使自省经验持久化 if reflection_engine: reflection_engine.memory = memory_mgr @@ -2416,4 +2421,3 @@ def emit_runtime_event( supervisor_report.risk_level.value, "; ".join(supervisor_report.recommended_actions[:3]), ) - diff --git a/minicode/config.py b/minicode/config.py index 722a51d..11281cc 100644 --- a/minicode/config.py +++ b/minicode/config.py @@ -259,6 +259,56 @@ def _known_openai_exposed_models(runtime: dict[str, Any] | None) -> list[str]: return list(list_openai_exposed_models(runtime)) +def _known_openai_agent_models(runtime: dict[str, Any] | None) -> list[str]: + from minicode.model_registry import model_id_supports_agent_tools + + return [ + model + for model in _known_openai_exposed_models(runtime) + if model_id_supports_agent_tools(model) + ] + + +def discovered_openai_fallbacks( + runtime: dict[str, Any] | None, + current_model: str | None = None, + *, + probe_openai_models: bool = True, +) -> list[str]: + """Return provider-exposed fallback models for a custom OpenAI host. + + A custom OpenAI-compatible gateway is not guaranteed to expose the built-in + OpenAI defaults. Prefer the gateway's own model catalog when it is + available. Network probing is opt-in so local readiness surfaces stay + deterministic; the runtime switcher enables it when it needs recovery. + """ + runtime = runtime or {} + if not _uses_custom_openai_compatible_host(runtime): + return [] + + from minicode.model_registry import ( + list_openai_exposed_models, + model_id_supports_agent_tools, + probe_openai_exposed_models, + ) + + active_model = str(current_model or runtime.get("model", "")).strip() + exposed_models = ( + probe_openai_exposed_models(runtime) + if probe_openai_models + else list_openai_exposed_models(runtime) + ) + return [ + model + for model in exposed_models + if ( + model + and model != active_model + and model_id_supports_agent_tools(model) + ) + ] + + def describe_fallback_guidance( runtime: dict[str, Any] | None, provider_name: str | None = None, @@ -278,7 +328,7 @@ def describe_fallback_guidance( configured = configured_model_fallbacks(runtime, provider_key) defaults = default_model_fallbacks(runtime, provider_key, current_model=active_model) exposed_openai_models = ( - _known_openai_exposed_models(runtime) + _known_openai_agent_models(runtime) if provider_key == "openai" and _uses_custom_openai_compatible_host(runtime) else [] ) @@ -301,25 +351,23 @@ def describe_fallback_guidance( ) if not configured: - if defaults: + if exposed_openai_models: + exposed_preview = ", ".join(exposed_openai_models[:3]) + guidance.append( + "Default failover is already available for this runtime through " + "provider-exposed models. This OpenAI-compatible provider currently " + f"exposes: {exposed_preview}. Set fallbackModels or " + f"{provider_specific_key} to one of the exposed models." + ) + elif defaults: preview = ", ".join(defaults[:3]) - if exposed_openai_models: - exposed_preview = ", ".join(exposed_openai_models[:3]) - guidance.append( - "Default failover is already available for this runtime" - f"{': ' + preview if preview else '.'}" - f" This OpenAI-compatible provider currently exposes: {exposed_preview}. " - f"Set fallbackModels or {provider_specific_key} to one of the exposed models " - "if the defaults are unavailable on the current provider." - ) - else: - guidance.append( - "Default failover is already available for this runtime" - f"{': ' + preview if preview else '.'}" - " If those models are still unavailable on the current provider, " - f"set fallbackModels or {provider_specific_key} to models that the provider actually exposes, " - "or switch provider credentials." - ) + guidance.append( + "Default failover is already available for this runtime" + f"{': ' + preview if preview else '.'}" + " If those models are still unavailable on the current provider, " + f"set fallbackModels or {provider_specific_key} to models that the provider actually exposes, " + "or switch provider credentials." + ) else: guidance.append( f"Add fallbackModels or {provider_specific_key} to enable model failover." @@ -669,7 +717,11 @@ def _is_valid_http_url(value: str | None) -> bool: return parsed.scheme in {"http", "https"} and bool(parsed.netloc) -def validate_provider_runtime(runtime: dict[str, Any]) -> list[str]: +def validate_provider_runtime( + runtime: dict[str, Any], + *, + probe_openai_models: bool = True, +) -> list[str]: """Validate the auth/base-url required by the detected provider. A generic API key is not enough: if the selected model routes to OpenAI, @@ -679,7 +731,11 @@ def validate_provider_runtime(runtime: dict[str, Any]) -> list[str]: from minicode.model_registry import Provider, detect_provider model = str(runtime.get("model", "")).strip() - provider = detect_provider(model, runtime) + provider = detect_provider( + model, + runtime, + probe_openai_models=probe_openai_models, + ) errors: list[str] = [] if provider == Provider.OPENAI: @@ -755,7 +811,12 @@ def validate_config(cwd: str | Path | None = None) -> tuple[bool, list[str]]: try: config = load_runtime_config(cwd) - errors.extend(validate_provider_runtime(config)) + errors.extend( + validate_provider_runtime( + config, + probe_openai_models=False, + ) + ) # 检查模型名称拼写 model = config.get("model", "") @@ -847,7 +908,11 @@ def format_config_diagnostic(cwd: str | Path | None = None) -> str: # Show provider info from minicode.model_registry import detect_provider, Provider - provider = detect_provider(model_name, config) + provider = detect_provider( + model_name, + config, + probe_openai_models=False, + ) lines.append(f" Provider: {provider.value}") lines.append(f" Channel: {describe_provider_channel(config, provider.value)}") diff --git a/minicode/headless.py b/minicode/headless.py index 7c5ef00..59536b1 100644 --- a/minicode/headless.py +++ b/minicode/headless.py @@ -233,6 +233,7 @@ def run_headless(prompt: str | None = None, allow_edits: bool = False) -> str: messages=messages, cwd=cwd, permissions=permissions, + memory_manager=memory_mgr, runtime=runtime, ) diff --git a/minicode/main.py b/minicode/main.py index 83ffe5b..4787294 100644 --- a/minicode/main.py +++ b/minicode/main.py @@ -621,6 +621,7 @@ def main() -> None: permissions=permissions, store=app_store, context_manager=context_mgr, + memory_manager=memory_mgr, runtime=runtime, ) permissions.end_turn() diff --git a/minicode/mcp.py b/minicode/mcp.py index 625ca6e..7806063 100644 --- a/minicode/mcp.py +++ b/minicode/mcp.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import sys import threading from dataclasses import asdict, dataclass from pathlib import Path @@ -139,8 +140,10 @@ def _prepare_spawn(command: str, args: list[str]) -> tuple[list[str] | str, dict Arguments are validated by _validate_mcp_args to contain no shell metacharacters, so shell=True is safe here. - On non-Windows (Linux/macOS), if command=="python" but shutil.which("python") - is None, falls back to "python3" (many Linux distros only ship python3). + On non-Windows (Linux/macOS), if command=="python" is unavailable, prefer the + interpreter running MiniCode and then fall back to "python3". This keeps MCP + helpers inside the same virtualenv/runtime instead of selecting a broken or + unrelated system Python. Returns (spawn_exec, extra_popen_kwargs): spawn_exec is a list for shell=False, a single string for shell=True. @@ -152,8 +155,11 @@ def _prepare_spawn(command: str, args: list[str]) -> tuple[list[str] | str, dict return subprocess.list2cmdline([resolved, *args]), {"shell": True} return [resolved, *args], {} else: - # Non-Windows: try python -> python3 fallback (Linux/macOS often lack plain "python") + # Non-Windows: keep python MCP helpers in the current runtime when the + # plain command is unavailable (Linux/macOS often lack plain "python"). if command == "python" and shutil.which(command) is None: + if sys.executable: + return [sys.executable, *args], {} resolved = shutil.which("python3") if resolved: return [resolved, *args], {} diff --git a/minicode/model_registry.py b/minicode/model_registry.py index 463ceb8..b885c2b 100644 --- a/minicode/model_registry.py +++ b/minicode/model_registry.py @@ -13,6 +13,7 @@ import json import os +import re import urllib.error import urllib.request from dataclasses import dataclass, field @@ -356,12 +357,14 @@ def probe_openai_exposed_models( def model_is_exposed_via_openai_runtime( model: str, runtime: dict[str, Any] | None = None, + *, + probe_openai_models: bool = True, ) -> bool: model_id = str(model or "").strip() if not model_id: return False exposed = list_openai_exposed_models(runtime) - if not exposed: + if not exposed and probe_openai_models: exposed = probe_openai_exposed_models(runtime) if not exposed: return False @@ -452,7 +455,12 @@ def model_is_exposed_via_openai_runtime( # Provider detection # --------------------------------------------------------------------------- -def detect_provider(model: str, runtime: dict[str, Any] | None = None) -> Provider: +def detect_provider( + model: str, + runtime: dict[str, Any] | None = None, + *, + probe_openai_models: bool = True, +) -> Provider: """Auto-detect which provider to use based on model name and config. Priority: @@ -478,9 +486,32 @@ def detect_provider(model: str, runtime: dict[str, Any] | None = None) -> Provid # Default to OpenRouter for vendor-prefixed models return Provider.OPENROUTER - if runtime and model_is_exposed_via_openai_runtime(model, runtime): + if runtime and model_is_exposed_via_openai_runtime( + model, + runtime, + probe_openai_models=probe_openai_models, + ): return Provider.OPENAI + if runtime and not probe_openai_models: + # Readiness and diagnostics must remain local-only. When the model is + # known, its catalog provider is authoritative; for an unknown model, + # use an explicitly configured OpenAI-compatible channel only when no + # Anthropic credentials indicate a competing primary channel. + normalized_model = model.casefold() + for model_name, model_info in BUILTIN_MODELS.items(): + if model_name.casefold() == normalized_model: + return model_info.provider + has_openai_channel = bool( + runtime.get("openaiApiKey") + and runtime.get("openaiBaseUrl") + ) + has_anthropic_channel = bool( + runtime.get("apiKey") or runtime.get("authToken") + ) + if has_openai_channel and not has_anthropic_channel: + return Provider.OPENAI + # 2. DeepSeek direct API detection if model_lower.startswith("deepseek") or "deepseek" in model_lower: if os.environ.get("DEEPSEEK_API_KEY"): @@ -531,6 +562,39 @@ def resolve_model_info(model: str, provider: Provider | None = None) -> ModelInf ) +_NON_AGENT_MODEL_MARKERS = ( + "audio", + "embedding", + "image", + "moderation", + "realtime", + "rerank", + "transcrib", + "tts", + "whisper", +) + + +def model_id_supports_agent_tools(model: str) -> bool: + """Conservatively identify model IDs suitable for MiniCode tool calls. + + OpenAI-compatible ``/v1/models`` responses generally expose IDs without + capability metadata. Known catalog entries still use their declared + ``supports_tools`` value; unknown IDs are filtered only when their name + clearly identifies a non-chat modality. + """ + normalized = str(model or "").strip().casefold() + if not normalized: + return False + + for model_name, info in BUILTIN_MODELS.items(): + if model_name.casefold() == normalized: + return info.supports_tools + + compact = re.sub(r"[^a-z0-9]+", "-", normalized) + return not any(marker in compact for marker in _NON_AGENT_MODEL_MARKERS) + + # --------------------------------------------------------------------------- # Provider configuration builder # --------------------------------------------------------------------------- diff --git a/minicode/model_switcher.py b/minicode/model_switcher.py index f0ce340..4a18712 100644 --- a/minicode/model_switcher.py +++ b/minicode/model_switcher.py @@ -22,6 +22,7 @@ detect_provider, list_available_models, list_openai_exposed_models, + model_id_supports_agent_tools, probe_openai_exposed_models, resolve_model_info, ) @@ -232,7 +233,11 @@ def _fallback_candidates(self) -> list[str]: ) ) if current_provider == "openai" and self._uses_custom_openai_compatible_host(): - candidates.extend(probe_openai_exposed_models(self._runtime)) + candidates.extend( + model + for model in probe_openai_exposed_models(self._runtime) + if model_id_supports_agent_tools(model) + ) for env_var in ("MINI_CODE_MODEL_FALLBACKS", provider_env): parsed = _parse_model_list(os.environ.get(env_var, "")) @@ -342,6 +347,8 @@ def _maybe_seed_runtime_family_defaults(self, model_name: str) -> None: self._runtime_family_defaults[key] = model_name def _can_attempt_model(self, model_name: str) -> bool: + if not model_id_supports_agent_tools(model_name): + return False try: provider_config = build_provider_config(model_name, self._runtime) except Exception: diff --git a/minicode/product_surfaces.py b/minicode/product_surfaces.py index 171d1d9..a73f45e 100644 --- a/minicode/product_surfaces.py +++ b/minicode/product_surfaces.py @@ -14,8 +14,8 @@ configured_model_fallbacks, describe_fallback_guidance, describe_provider_channel, + discovered_openai_fallbacks, default_model_fallbacks, - effective_model_fallbacks, load_runtime_config, project_extensions_dir, project_managed_policy_path, @@ -328,22 +328,42 @@ def build_delegation_status() -> DelegationStatus: def _classify_fallbacks( runtime: dict[str, Any], provider: str, + *, + probe_openai_models: bool = True, ) -> tuple[list[str], list[str], list[str]]: - fallback_candidates = [ - candidate - for candidate in effective_model_fallbacks( + current_model = str(runtime.get("model", "")).strip() + configured = configured_model_fallbacks(runtime, provider) + discovered = ( + discovered_openai_fallbacks( runtime, - provider, - current_model=str(runtime.get("model", "")).strip(), + current_model=current_model, + probe_openai_models=probe_openai_models, ) - if candidate != str(runtime.get("model", "")).strip() - ] + if provider == "openai" + else [] + ) + defaults = discovered or default_model_fallbacks( + runtime, + provider, + current_model=current_model, + ) + fallback_candidates: list[str] = [] + seen: set[str] = set() + for candidate in [*configured, *defaults]: + normalized = str(candidate or "").strip() + if not normalized or normalized == current_model or normalized in seen: + continue + seen.add(normalized) + fallback_candidates.append(normalized) viable: list[str] = [] issues: list[str] = [] for candidate in fallback_candidates: candidate_runtime = dict(runtime) candidate_runtime["model"] = candidate - candidate_issues = validate_provider_runtime(candidate_runtime) + candidate_issues = validate_provider_runtime( + candidate_runtime, + probe_openai_models=probe_openai_models, + ) if candidate_issues: issues.append(f"Fallback '{candidate}' is not locally ready: {candidate_issues[0]}") continue @@ -614,25 +634,42 @@ def build_readiness_report( ) -> ReadinessReport: try: effective_runtime = runtime or load_runtime_config(cwd) - issues = validate_provider_runtime(effective_runtime) + issues = validate_provider_runtime( + effective_runtime, + probe_openai_models=False, + ) provider = detect_provider( str(effective_runtime.get("model", "")).strip(), effective_runtime, + probe_openai_models=False, ).value provider_ready = not issues configured_fallbacks = configured_model_fallbacks(effective_runtime, provider) - default_fallbacks = [ - candidate - for candidate in default_model_fallbacks( + discovered_fallbacks = ( + discovered_openai_fallbacks( effective_runtime, - provider, current_model=str(effective_runtime.get("model", "")).strip(), + probe_openai_models=False, + ) + if provider == "openai" + else [] + ) + default_fallbacks = [ + candidate + for candidate in ( + discovered_fallbacks + or default_model_fallbacks( + effective_runtime, + provider, + current_model=str(effective_runtime.get("model", "")).strip(), + ) ) if candidate not in configured_fallbacks ] fallback_candidates, viable_fallbacks, fallback_issues = _classify_fallbacks( effective_runtime, provider, + probe_openai_models=False, ) provider_channel = describe_provider_channel(effective_runtime, provider) fallback_guidance = describe_fallback_guidance( diff --git a/minicode/tui/input_handler.py b/minicode/tui/input_handler.py index 2d71259..549294a 100644 --- a/minicode/tui/input_handler.py +++ b/minicode/tui/input_handler.py @@ -717,6 +717,7 @@ def _run_agent_background(): on_thinking_chunk=on_thinking_chunk, store=state.app_state, context_manager=args.context_manager, + memory_manager=memory_mgr, runtime=args.runtime, ) if args.context_manager is not None: diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index f578cd6..9b66763 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -763,8 +763,16 @@ def test_model_switcher_probes_provider_exposed_models_on_custom_openai_host(mon def _fake_probe(current_runtime): probed.append(True) - current_runtime["_openaiExposedModels"] = ["claude-sonnet-4-6"] - return ("claude-sonnet-4-6",) + current_runtime["_openaiExposedModels"] = [ + "claude-sonnet-4-6", + "gpt-image-1.5", + "gpt-4o-audio-preview", + ] + return ( + "claude-sonnet-4-6", + "gpt-image-1.5", + "gpt-4o-audio-preview", + ) monkeypatch.setattr("minicode.model_switcher.probe_openai_exposed_models", _fake_probe) diff --git a/tests/test_config.py b/tests/test_config.py index ba5b194..bd9c643 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ from minicode.config import ( describe_fallback_guidance, default_model_fallbacks, + discovered_openai_fallbacks, effective_model_fallbacks, format_config_diagnostic, load_runtime_config, @@ -85,6 +86,25 @@ def test_detect_provider_accepts_provider_exposed_model_on_custom_openai_host() assert provider == Provider.OPENAI +def test_detect_provider_can_classify_unknown_openai_model_without_probe( + monkeypatch, +) -> None: + def fail_probe(*_args, **_kwargs): + raise AssertionError("local provider detection must not probe the gateway") + + monkeypatch.setattr("minicode.model_registry.probe_openai_exposed_models", fail_probe) + provider = detect_provider( + "qwen3.7-max", + { + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://provider.example.test/v1", + }, + probe_openai_models=False, + ) + + assert provider == Provider.OPENAI + + def test_validate_provider_runtime_rejects_non_exposed_model_on_custom_openai_host() -> None: errors = validate_provider_runtime( { @@ -323,6 +343,32 @@ def test_format_config_diagnostic_scopes_openai_provider_details(monkeypatch) -> assert "Fallback Models: gpt-4o, gpt-4o-mini" in result assert "Base URL: https://ai.space.cx" not in result assert "ANTHROPIC_AUTH_TOKEN" not in result + + +def test_format_config_diagnostic_does_not_probe_openai_gateway(monkeypatch) -> None: + runtime = { + "model": "gpt-4o", + "openaiBaseUrl": "https://api.openai.com", + "openaiApiKey": "openai-key", + "baseUrl": "https://api.anthropic.com", + "apiKey": "", + "authToken": "", + "fallbackModels": [], + "openaiFallbackModels": [], + "mcpServers": {}, + "toolProfile": "core", + } + + def fail_probe(*_args, **_kwargs): + raise AssertionError("configuration diagnostics must remain local-only") + + monkeypatch.setattr(config_module, "load_runtime_config", lambda cwd=None: runtime) + monkeypatch.setattr("minicode.model_registry.probe_openai_exposed_models", fail_probe) + + result = format_config_diagnostic() + + assert "Provider: openai" in result + assert "Status: OK" in result assert "CUSTOM_API_KEY" not in result @@ -340,11 +386,41 @@ def test_describe_fallback_guidance_prefers_provider_exposed_models_when_default assert guidance assert "default failover is already available" in guidance[0].lower() - assert "gpt-4o, gpt-4o-mini" in guidance[0] assert "currently exposes: claude-sonnet-4-6, claude-haiku-4-5-20251001" in guidance[0].lower() + assert "gpt-4o, gpt-4o-mini" not in guidance[0] assert "add fallbackmodels or openaifallbackmodels to enable model failover" not in guidance[0].lower() +def test_discovered_openai_fallbacks_excludes_active_model() -> None: + assert discovered_openai_fallbacks( + { + "model": "qwen3.7-max", + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://ai.space.cx", + "_openaiExposedModels": ["qwen3.7-max", "kimi-k2.7-code"], + }, + current_model="qwen3.7-max", + ) == ["kimi-k2.7-code"] + + +def test_discovered_openai_fallbacks_excludes_non_agent_modalities() -> None: + assert discovered_openai_fallbacks( + { + "model": "qwen3.7-max", + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://ai.space.cx", + "_openaiExposedModels": [ + "qwen3.7-max", + "kimi-k2.7-code", + "gpt-image-1.5", + "gpt-4o-audio-preview", + "gpt-4o-realtime-preview", + ], + }, + current_model="qwen3.7-max", + ) == ["kimi-k2.7-code"] + + def test_load_runtime_config_falls_back_to_model_for_missing_anthropic_family_defaults(monkeypatch) -> None: monkeypatch.setattr( config_module, diff --git a/tests/test_functional_completeness.py b/tests/test_functional_completeness.py index 9bafc5f..a0bb696 100644 --- a/tests/test_functional_completeness.py +++ b/tests/test_functional_completeness.py @@ -13,6 +13,7 @@ from __future__ import annotations import os +import shlex import tempfile from pathlib import Path @@ -123,7 +124,10 @@ def test_grep_files_tool(self, context): def test_run_command_tool(self, context): """Test run_command_tool executes successfully.""" from minicode.tools.run_command import run_command_tool - result = run_command_tool.run({"command": "python --version"}, context) + result = run_command_tool.run( + {"command": f"{shlex.quote(sys.executable)} --version"}, + context, + ) assert result.ok assert "Python" in result.output diff --git a/tests/test_headless.py b/tests/test_headless.py index fe8e8dd..bcc6fa1 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -72,6 +72,7 @@ def test_run_headless_forwards_runtime_to_agent_turn(monkeypatch, tmp_path: Path def _fake_run_agent_turn(**kwargs): captured["runtime"] = kwargs["runtime"] + captured["memory_manager"] = kwargs["memory_manager"] return [{"role": "assistant", "content": "ok"}] monkeypatch.setattr("minicode.agent_loop.run_agent_turn", _fake_run_agent_turn) @@ -80,6 +81,8 @@ def _fake_run_agent_turn(**kwargs): assert response == "ok" assert captured["runtime"] is runtime + assert isinstance(captured["memory_manager"], _DummyMemoryManager) + assert captured["memory_manager"].project_root == tmp_path def test_run_headless_provider_failure_uses_runtime_channel_details( diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 314ad9e..63e3ba8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -57,6 +57,18 @@ def test_create_mcp_backed_tools_supports_newline_json(tmp_path: Path) -> None: mcp["dispose"]() +def test_prepare_spawn_uses_running_python_when_plain_python_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(mcp_module.shutil, "which", lambda command: None) + monkeypatch.setattr(mcp_module.sys, "executable", "/runtime/python") + + spawn_exec, extra = mcp_module._prepare_spawn("python", ["server.py"]) + + assert spawn_exec == ["/runtime/python", "server.py"] + assert extra == {} + + def test_pending_request_fails_when_server_exits(tmp_path: Path) -> None: client = _client(tmp_path, mode="exit_on_call") client.start() diff --git a/tests/test_memory_integration.py b/tests/test_memory_integration.py index 316b0db..4495a81 100644 --- a/tests/test_memory_integration.py +++ b/tests/test_memory_integration.py @@ -124,6 +124,38 @@ def memory_with_entries(memory_manager: MemoryManager) -> MemoryManager: class TestMemoryContextManagerIntegration: """Memory injected into prompt works with ContextManager lifecycle.""" + def test_agent_loop_reuses_supplied_memory_manager( + self, + mock_model, + tools, + tmp_workspace, + auto_allow_permissions, + monkeypatch, + ): + """A resumed frontend must keep one memory state object per turn.""" + context = ContextManager(model="default", context_window=1000) + memory_manager = MemoryManager(project_root=tmp_workspace) + + def fail_if_recreated(*_args, **_kwargs): + raise AssertionError("agent loop recreated the frontend memory manager") + + monkeypatch.setattr("minicode.agent_loop.MemoryManager", fail_if_recreated) + + result = run_agent_turn( + model=mock_model, + tools=tools, + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "/ls"}, + ], + cwd=str(tmp_workspace), + permissions=auto_allow_permissions, + context_manager=context, + memory_manager=memory_manager, + ) + + assert result + def test_memory_context_injection_with_context_manager(self, memory_with_entries): context = memory_with_entries.get_relevant_context() assert context != "" diff --git a/tests/test_model_selection_controller.py b/tests/test_model_selection_controller.py index 81e56ab..6b3df71 100644 --- a/tests/test_model_selection_controller.py +++ b/tests/test_model_selection_controller.py @@ -3,6 +3,7 @@ ModelSelectionSignal, ReasoningEffort, format_model_status, + model_id_supports_agent_tools, resolve_model_info, ) @@ -79,3 +80,11 @@ def test_model_status_includes_cybernetic_recommendation(self): assert "Cybernetic Recommendation" in status assert "Effort:" in status assert "Score:" in status + + def test_dynamic_model_ids_filter_non_agent_modalities(self): + assert model_id_supports_agent_tools("qwen3.7-max") is True + assert model_id_supports_agent_tools("kimi-k2.7-code") is True + assert model_id_supports_agent_tools("gpt-image-1.5") is False + assert model_id_supports_agent_tools("gpt-4o-audio-preview") is False + assert model_id_supports_agent_tools("gpt-4o-realtime-preview") is False + assert model_id_supports_agent_tools("o1") is False diff --git a/tests/test_model_switching.py b/tests/test_model_switching.py index 53273ef..ed164d8 100644 --- a/tests/test_model_switching.py +++ b/tests/test_model_switching.py @@ -147,3 +147,24 @@ def _boom(*, model, tools, runtime): assert result.success is False assert switcher.current_model == "claude-sonnet-4-6" # unchanged assert runtime["model"] == "claude-sonnet-4-6" # runtime unchanged + + +def test_fallback_candidates_exclude_models_without_tool_support(monkeypatch): + monkeypatch.setattr( + "minicode.model_switcher.build_provider_config", + lambda model, runtime=None: type("Provider", (), {"api_key": "test-key"})(), + ) + + switcher = ModelSwitcher( + current_model="claude-sonnet-4-6", + current_runtime={ + "model": "claude-sonnet-4-6", + "fallbackModels": ["o1", "gpt-4o-audio-preview", "gpt-4o"], + }, + current_tools=object(), + ) + + candidates = switcher._fallback_candidates() + assert "gpt-4o" in candidates + assert "o1" not in candidates + assert "gpt-4o-audio-preview" not in candidates diff --git a/tests/test_product_surfaces.py b/tests/test_product_surfaces.py index 9206fca..b452ac1 100644 --- a/tests/test_product_surfaces.py +++ b/tests/test_product_surfaces.py @@ -106,6 +106,46 @@ def test_build_readiness_report_uses_default_fallback_coverage() -> None: assert "local-only" in preflight["live-smoke-readiness"]["summary"] +def test_build_readiness_report_uses_custom_gateway_exposed_fallbacks() -> None: + report = build_readiness_report( + ".", + runtime={ + "model": "qwen3.7-max", + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://ai.space.cx", + "_openaiExposedModels": ["qwen3.7-max", "kimi-k2.7-code"], + }, + ) + + assert report.status == "ready" + assert report.provider_ready is True + assert report.fallback_ready is True + assert report.fallback_candidates == ["kimi-k2.7-code"] + assert report.viable_fallbacks == ["kimi-k2.7-code"] + assert report.fallback_config_examples == [] + assert not any("gpt-4o" in issue for issue in report.issues) + + +def test_build_readiness_report_does_not_probe_custom_gateway(monkeypatch) -> None: + def fail_probe(*_args, **_kwargs): + raise AssertionError("readiness preflight must remain local-only") + + monkeypatch.setattr("minicode.model_registry.probe_openai_exposed_models", fail_probe) + report = build_readiness_report( + ".", + runtime={ + "model": "qwen3.7-max", + "openaiApiKey": "openai-key", + "openaiBaseUrl": "https://provider.example.test/v1", + }, + ) + + assert report.provider == "openai" + assert report.provider_ready is True + assert report.fallback_ready is True + assert report.fallback_candidates[:2] == ["gpt-4o", "gpt-4o-mini"] + + def test_build_readiness_report_surfaces_provider_config_risk() -> None: report = build_readiness_report( ".", diff --git a/tests/test_release_integration.py b/tests/test_release_integration.py index 45888c3..7b6226c 100644 --- a/tests/test_release_integration.py +++ b/tests/test_release_integration.py @@ -5,6 +5,7 @@ import subprocess import sys import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from minicode.agent_loop import run_agent_turn @@ -974,6 +975,190 @@ def test_release_agent_loop_executes_real_tool_chain(tmp_path: Path) -> None: assert "Release Fixtur" in messages[-1]["content"] +def test_release_openai_protocol_fallback_completes_tool_turn(tmp_path: Path, monkeypatch) -> None: + """Exercise provider failure, automatic fallback, and tool continuation locally.""" + primary_model = "qwen3.7-max" + fallback_model = "kimi-k2.7-code" + requests: list[dict] = [] + + class FallbackProtocolHandler(BaseHTTPRequestHandler): + server_version = "MiniCodeFallbackFixture/1.0" + + def log_message(self, _format: str, *_args) -> None: + return + + def _send_json(self, status: int, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path.rstrip("/") == "/v1/models": + self._send_json( + 200, + { + "object": "list", + "data": [ + {"id": primary_model, "object": "model"}, + {"id": fallback_model, "object": "model"}, + ], + }, + ) + return + self._send_json(404, {"error": {"message": "not found"}}) + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) + requests.append(payload) + model = payload.get("model") + messages = payload.get("messages", []) + + if model == primary_model: + self._send_json( + 503, + { + "error": { + "message": f"No available channel for model {primary_model}", + "type": "server_error", + } + }, + ) + return + + if model != fallback_model: + self._send_json( + 400, + {"error": {"message": f"unexpected model: {model}"}}, + ) + return + + if any(message.get("role") == "tool" for message in messages): + response = "fallback succeeded after tool execution" + self._send_json( + 200, + { + "id": "chatcmpl-fallback-final", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": response}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + }, + ) + return + + self._send_json( + 200, + { + "id": "chatcmpl-fallback-tool", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-read-marker", + "type": "function", + "function": { + "name": "read_marker", + "arguments": json.dumps({"path": "README.md"}), + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + }, + ) + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "README.md").write_text("# fallback fixture\n", encoding="utf-8") + server = ThreadingHTTPServer(("127.0.0.1", 0), FallbackProtocolHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + from minicode.model_registry import create_model_adapter + from minicode.tooling import ToolDefinition, ToolResult + + def run_read_marker(input_data: dict, _context) -> ToolResult: + return ToolResult(ok=True, output=f"marker:{input_data['path']}") + + tools = ToolRegistry( + [ + ToolDefinition( + name="read_marker", + description="Read the deterministic release marker.", + input_schema={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + validator=lambda value: value, + run=run_read_marker, + ) + ] + ) + runtime = { + "model": primary_model, + "configuredModel": primary_model, + "openaiApiKey": "local-test-key", + "openaiBaseUrl": f"http://127.0.0.1:{server.server_port}", + "openaiFallbackModels": [fallback_model], + "_openaiExposedModels": [primary_model, fallback_model], + "maxOutputTokens": 128, + } + model = create_model_adapter(primary_model, tools, runtime=runtime) + monkeypatch.setenv("MINICODE_MODEL_TIMEOUT", "5") + + messages = run_agent_turn( + model=model, + tools=tools, + messages=[ + {"role": "system", "content": "Use the available tool, then report the result."}, + {"role": "user", "content": "Read the marker and finish the task."}, + ], + cwd=str(workspace), + permissions=PermissionManager( + str(workspace), + prompt=lambda _request: {"decision": "allow_once"}, + ), + runtime=runtime, + max_steps=5, + ) + finally: + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + + assert [request.get("model") for request in requests] == [primary_model, fallback_model, fallback_model] + assert any( + message["role"] == "assistant_tool_call" and message["toolName"] == "read_marker" + for message in messages + ) + assert any( + message["role"] == "tool_result" and "marker:README.md" in message["content"] + for message in messages + ) + assert messages[-1]["role"] == "assistant" + assert "fallback succeeded" in messages[-1]["content"] + assert runtime["model"] == fallback_model + + class PromptCapturingModel: def __init__(self) -> None: self.system_prompt = "" diff --git a/tests/test_release_readiness_benchmark.py b/tests/test_release_readiness_benchmark.py index 6c31ef9..a139fd4 100644 --- a/tests/test_release_readiness_benchmark.py +++ b/tests/test_release_readiness_benchmark.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +import subprocess from pathlib import Path from unittest.mock import patch import json @@ -78,6 +79,41 @@ def test_release_readiness_command_summarizes_readiness_json(tmp_path: Path) -> assert check.summary == "readiness warning (fallback-gap)" +def test_release_pytest_command_does_not_inherit_live_provider_environment( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "live-secret") + monkeypatch.setenv("OPENAI_BASE_URL", "https://ai.space.cx") + monkeypatch.setenv("MINI_CODE_MODEL", "qwen3.7-max") + monkeypatch.setenv("OPENAI_MODEL_FALLBACKS", "kimi-k2.7-code") + captured: dict[str, str] = {} + + def fake_run(*args, **kwargs): + captured.update(kwargs["env"]) + return subprocess.CompletedProcess( + args=args[0], + returncode=0, + stdout="pytest ok\n", + stderr="", + ) + + monkeypatch.setattr("benchmarks.release_readiness.subprocess.run", fake_run) + + check = _run_command( + "pytest-q", + [sys.executable, "-m", "pytest", "-q"], + cwd=tmp_path, + timeout=30, + ) + + assert check.status == "passed" + assert "OPENAI_API_KEY" not in captured + assert "OPENAI_BASE_URL" not in captured + assert "MINI_CODE_MODEL" not in captured + assert "OPENAI_MODEL_FALLBACKS" not in captured + + def test_release_readiness_snapshot_preserves_local_preflight() -> None: report = ReadinessReport( status="warning", diff --git a/tests/test_tty_app.py b/tests/test_tty_app.py index 2206804..0ad3ca6 100644 --- a/tests/test_tty_app.py +++ b/tests/test_tty_app.py @@ -11,14 +11,27 @@ ) import minicode.tui.input_handler as input_handler_module from minicode.context_manager import ContextManager +from minicode.memory import MemoryManager, MemoryScope from minicode.permissions import PermissionManager -from minicode.session import FileCheckpoint, SessionData, SessionMetadata +from minicode.prompt import build_system_prompt_bundle +from minicode.session import ( + FileCheckpoint, + SessionData, + SessionMetadata, + create_file_checkpoint, + create_new_session, + rewind_session, +) from minicode.tooling import ToolRegistry from minicode.tui.runtime_control import _ThrottledRenderer as RuntimeThrottledRenderer from minicode.tui.event_flow import _handle_event from minicode.tui.input_parser import KeyEvent from minicode.tui.renderer import _decorate_session_feed_body -from minicode.tui.session_flow import finalize_tty_session +from minicode.tui.session_flow import ( + build_tty_runtime_state, + finalize_tty_session, + load_or_create_session, +) from minicode.tui.state import ScreenState, TtyAppArgs from minicode.tui.transcript import format_runtime_summary_line, format_transcript_text from minicode.tui.types import TranscriptEntry @@ -311,6 +324,79 @@ def test_finalize_tty_session_persists_runtime_metadata() -> None: ] +def test_tty_resume_rehydrates_session_checkpoint_and_memory(tmp_path, monkeypatch) -> None: + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + monkeypatch.setattr("minicode.session.SESSIONS_DIR", sessions_dir) + monkeypatch.setattr("minicode.session.MINI_CODE_DIR", tmp_path) + monkeypatch.setattr("minicode.memory.MINI_CODE_DIR", tmp_path) + + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "README.md" + target.write_text("before", encoding="utf-8") + + memory_manager = MemoryManager(project_root=workspace) + memory_manager.add_entry( + MemoryScope.PROJECT, + "decision", + "Resume continuity marker: keep the public API stable.", + ) + + session = create_new_session(str(workspace)) + session.messages = [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "Continue the interrupted task."}, + ] + session.transcript_entries = [ + {"id": 1, "kind": "user", "body": "Continue the interrupted task."}, + ] + checkpoint = create_file_checkpoint( + session, + file_path=str(target), + existed=True, + previous_content="before", + ) + assert checkpoint is not None + target.write_text("after", encoding="utf-8") + + rewound, restored_checkpoints = rewind_session(session.session_id) + assert rewound is not None + assert restored_checkpoints + assert target.read_text(encoding="utf-8") == "before" + + resumed = load_or_create_session(str(workspace), session.session_id) + resumed_memory = MemoryManager(project_root=workspace) + permissions = PermissionManager(str(workspace)) + args, state = build_tty_runtime_state( + runtime={"model": "mock"}, + tools=ToolRegistry([]), + model=object(), + messages=[{"role": "system", "content": "fresh process"}], + cwd=str(workspace), + permissions=permissions, + session=resumed, + memory_manager=resumed_memory, + ) + + assert args.messages == resumed.messages + assert [entry.body for entry in state.transcript] == [ + "Continue the interrupted task." + ] + memory_context = resumed_memory.get_relevant_context(query="continuity") + bundle = build_system_prompt_bundle( + str(workspace), + permissions.get_summary(), + { + "skills": [], + "mcpServers": [], + "memory_context": memory_context, + "runtime": {"model": "mock"}, + }, + ) + assert "Resume continuity marker" in bundle.prompt + + def test_summarize_tool_input_formats_patch_file() -> None: summary = summarize_tool_input( "patch_file", @@ -398,6 +484,10 @@ def test_tty_input_passes_and_persists_context_manager(tmp_path, monkeypatch) -> captured: dict = {} saved: list[ContextManager] = [] context_manager = ContextManager(model="default", context_window=1000) + memory_manager = SimpleNamespace( + handle_user_memory_input=lambda _input: None, + get_relevant_context=lambda **_kwargs: "", + ) def fake_run_agent_turn(**kwargs): captured.update(kwargs) @@ -416,6 +506,7 @@ def fake_run_agent_turn(**kwargs): messages=[{"role": "system", "content": "sys"}], cwd=str(tmp_path), permissions=PermissionManager(str(tmp_path)), + memory_manager=memory_manager, context_manager=context_manager, ) @@ -423,6 +514,7 @@ def fake_run_agent_turn(**kwargs): state.agent_thread.join(timeout=5) assert captured["context_manager"] is context_manager + assert captured["memory_manager"] is memory_manager assert saved == [context_manager] assert state.agent_result["messages"][-1] == {"role": "assistant", "content": "done"} From c60b1edf1498618fbaa01c0ba351f59cbefceda2 Mon Sep 17 00:00:00 2001 From: QUSETIONS <184124238+QUSETIONS@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:37:41 +0800 Subject: [PATCH 2/2] Fix Windows command execution fallbacks --- minicode/mcp.py | 28 +++++++++++++-------------- tests/test_functional_completeness.py | 3 +-- tests/test_mcp.py | 1 + 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/minicode/mcp.py b/minicode/mcp.py index 7806063..ef2a55a 100644 --- a/minicode/mcp.py +++ b/minicode/mcp.py @@ -140,29 +140,29 @@ def _prepare_spawn(command: str, args: list[str]) -> tuple[list[str] | str, dict Arguments are validated by _validate_mcp_args to contain no shell metacharacters, so shell=True is safe here. - On non-Windows (Linux/macOS), if command=="python" is unavailable, prefer the - interpreter running MiniCode and then fall back to "python3". This keeps MCP - helpers inside the same virtualenv/runtime instead of selecting a broken or - unrelated system Python. + If command=="python" is unavailable, prefer the interpreter running MiniCode + and then fall back to "python3". This keeps MCP helpers inside the same + virtualenv/runtime instead of selecting a broken or unrelated system Python. Returns (spawn_exec, extra_popen_kwargs): spawn_exec is a list for shell=False, a single string for shell=True. """ + resolved = shutil.which(command) if os.name == "nt": - resolved = shutil.which(command) if resolved: if resolved.lower().endswith((".cmd", ".bat")): return subprocess.list2cmdline([resolved, *args]), {"shell": True} return [resolved, *args], {} - else: - # Non-Windows: keep python MCP helpers in the current runtime when the - # plain command is unavailable (Linux/macOS often lack plain "python"). - if command == "python" and shutil.which(command) is None: - if sys.executable: - return [sys.executable, *args], {} - resolved = shutil.which("python3") - if resolved: - return [resolved, *args], {} + + # Keep python MCP helpers in the current runtime when the plain command is + # unavailable. This applies on Windows too, where the launcher may be + # absent even though the current interpreter is available. + if command == "python" and resolved is None: + if sys.executable: + return [sys.executable, *args], {} + fallback = shutil.which("python3") + if fallback: + return [fallback, *args], {} return [command, *args], {} diff --git a/tests/test_functional_completeness.py b/tests/test_functional_completeness.py index a0bb696..3922a7c 100644 --- a/tests/test_functional_completeness.py +++ b/tests/test_functional_completeness.py @@ -13,7 +13,6 @@ from __future__ import annotations import os -import shlex import tempfile from pathlib import Path @@ -125,7 +124,7 @@ def test_run_command_tool(self, context): """Test run_command_tool executes successfully.""" from minicode.tools.run_command import run_command_tool result = run_command_tool.run( - {"command": f"{shlex.quote(sys.executable)} --version"}, + {"command": sys.executable, "args": ["--version"]}, context, ) assert result.ok diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 63e3ba8..beec0fc 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -60,6 +60,7 @@ def test_create_mcp_backed_tools_supports_newline_json(tmp_path: Path) -> None: def test_prepare_spawn_uses_running_python_when_plain_python_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(mcp_module.os, "name", "nt") monkeypatch.setattr(mcp_module.shutil, "which", lambda command: None) monkeypatch.setattr(mcp_module.sys, "executable", "/runtime/python")