|
| 1 | +"""Integration tests: exercise the fixed subsystems end-to-end together. |
| 2 | +
|
| 3 | +Each "round" drives a realistic flow through the real default tool registry + |
| 4 | +agent loop / session / memory / mcp / compaction / prompt, with the fixes from |
| 5 | +this session in place. Run repeatedly to check stability (no flakes, no |
| 6 | +regressions across the bug-fix batch). |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import sys |
| 12 | +import tempfile |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
| 17 | +from minicode.agent_loop import run_agent_turn |
| 18 | +from minicode.context_compactor import ToolResultBudgetManager |
| 19 | +from minicode.context_manager import ContextManager, compute_context_stats |
| 20 | +from minicode.headless import _make_auto_approve_prompt |
| 21 | +from minicode.memory import MemoryEntry, MemoryManager, MemoryScope |
| 22 | +from minicode.mcp import create_mcp_backed_tools |
| 23 | +from minicode.micro_compact import MicroCompactor, MicroCompactorConfig |
| 24 | +from minicode.permissions import PermissionManager |
| 25 | +from minicode.prompt import build_system_prompt_bundle |
| 26 | +from minicode.session import create_new_session, load_session, save_session |
| 27 | +from minicode.tools import create_default_tool_registry |
| 28 | +from minicode.tooling import ToolContext |
| 29 | +from minicode.types import AgentStep, ModelAdapter, ChatMessage |
| 30 | + |
| 31 | + |
| 32 | +# --------------------------------------------------------------------------- |
| 33 | +# Helpers |
| 34 | +# --------------------------------------------------------------------------- |
| 35 | + |
| 36 | + |
| 37 | +class ScriptedModel(ModelAdapter): |
| 38 | + def __init__(self, steps: list[AgentStep]) -> None: |
| 39 | + self._steps = steps |
| 40 | + self.calls = 0 |
| 41 | + |
| 42 | + def next(self, messages, on_stream_chunk=None, store=None): |
| 43 | + step = self._steps[self.calls] if self.calls < len(self._steps) else AgentStep(type="assistant", content="(done)") |
| 44 | + self.calls += 1 |
| 45 | + return step |
| 46 | + |
| 47 | + |
| 48 | +def _tools(cwd): |
| 49 | + return create_default_tool_registry(str(cwd), runtime={"model": "mock"}) |
| 50 | + |
| 51 | + |
| 52 | +def _perm_allow(cwd): |
| 53 | + return PermissionManager(str(cwd), prompt=_make_auto_approve_prompt()) |
| 54 | + |
| 55 | + |
| 56 | +def _tr(name, body, tid): |
| 57 | + return {"role": "tool_result", "content": body, "data": {"tool_name": name, "tool_id": tid}} |
| 58 | + |
| 59 | + |
| 60 | +# --------------------------------------------------------------------------- |
| 61 | +# Round 1: multi-turn write -> read -> edit, with session save/resume |
| 62 | +# --------------------------------------------------------------------------- |
| 63 | + |
| 64 | + |
| 65 | +def test_round1_multi_turn_edits_and_session_resume(tmp_path): |
| 66 | + tools = _tools(tmp_path) |
| 67 | + perm = _perm_allow(tmp_path) |
| 68 | + target = tmp_path / "r1.txt" |
| 69 | + |
| 70 | + # Turn 1: write |
| 71 | + m1 = ScriptedModel([ |
| 72 | + AgentStep(type="tool_calls", calls=[{"id": "1", "toolName": "write_file", "input": {"path": str(target), "content": "v1\n"}}]), |
| 73 | + AgentStep(type="assistant", content="wrote v1"), |
| 74 | + ]) |
| 75 | + run_agent_turn(model=m1, tools=tools, messages=[{"role": "system", "content": "s"}], cwd=str(tmp_path), permissions=perm, runtime={"model": "mock"}) |
| 76 | + assert target.read_text(encoding="utf-8") == "v1\n" |
| 77 | + |
| 78 | + # Turn 2: read it back |
| 79 | + m2 = ScriptedModel([ |
| 80 | + AgentStep(type="tool_calls", calls=[{"id": "2", "toolName": "read_file", "input": {"path": str(target)}}]), |
| 81 | + AgentStep(type="assistant", content="read it"), |
| 82 | + ]) |
| 83 | + res = run_agent_turn(model=m2, tools=tools, messages=[{"role": "system", "content": "s"}], cwd=str(tmp_path), permissions=perm, runtime={"model": "mock"}) |
| 84 | + assert any("v1" in (m.get("content") or "") for m in res if m.get("role") == "tool_result") |
| 85 | + |
| 86 | + # Session round-trip (session update_metadata must not crash on any content) |
| 87 | + s = create_new_session(str(tmp_path)) |
| 88 | + s.messages = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": None}] # None content |
| 89 | + save_session(s) |
| 90 | + loaded = load_session(s.session_id) |
| 91 | + assert loaded is not None and loaded.messages[0]["content"] == "hi" |
| 92 | + tools.dispose() |
| 93 | + |
| 94 | + |
| 95 | +# --------------------------------------------------------------------------- |
| 96 | +# Round 2: MCP-backed tool (spawn + protocol path that the npx fix touched) |
| 97 | +# --------------------------------------------------------------------------- |
| 98 | + |
| 99 | + |
| 100 | +def test_round2_mcp_echo_end_to_end(tmp_path): |
| 101 | + fake = Path(__file__).resolve().parent / "fixtures" / "fake_mcp_server.py" |
| 102 | + mcp = create_mcp_backed_tools(cwd=str(tmp_path), mcp_servers={ |
| 103 | + "fake": {"command": "python", "args": [str(fake)], "protocol": "newline-json"}}) |
| 104 | + echo = next((t for t in mcp["tools"] if t.name == "mcp__fake__echo"), None) |
| 105 | + assert echo is not None, [t.name for t in mcp["tools"]] |
| 106 | + result = echo.run({"text": "integration"}, ToolContext(cwd=str(tmp_path), permissions=None, session=None)) |
| 107 | + assert result.ok and result.output == "echo:integration" |
| 108 | + mcp["dispose"]() |
| 109 | + |
| 110 | + |
| 111 | +# --------------------------------------------------------------------------- |
| 112 | +# Round 3: memory add (incl. None content) + retrieval |
| 113 | +# --------------------------------------------------------------------------- |
| 114 | + |
| 115 | + |
| 116 | +def test_round3_memory_with_none_content(tmp_path): |
| 117 | + mgr = MemoryManager(project_root=tmp_path) |
| 118 | + mf = mgr.memories[MemoryScope.PROJECT] |
| 119 | + mf.entries.append(MemoryEntry(id="bad", content=None, scope=MemoryScope.PROJECT, category="c")) |
| 120 | + mf.entries.append(MemoryEntry(id="good", content="how to configure logging level", scope=MemoryScope.PROJECT, category="convention")) |
| 121 | + results = mgr.search("logging level") # must not crash on None entry |
| 122 | + assert any("logging" in e.content for e in results) |
| 123 | + |
| 124 | + |
| 125 | +# --------------------------------------------------------------------------- |
| 126 | +# Round 4: microcompactor budget compaction trims oldest (the fixed behavior) |
| 127 | +# --------------------------------------------------------------------------- |
| 128 | + |
| 129 | + |
| 130 | +def test_round4_microcompactor_budget_trims_oldest(): |
| 131 | + def tr(name, body, tid): |
| 132 | + return {"role": "tool_result", "content": body, "data": {"tool_name": name, "tool_id": tid}} |
| 133 | + def tu(name, tid): |
| 134 | + return {"role": "tool_use", "data": {"tool_name": name, "tool_id": tid}} |
| 135 | + def a(t): |
| 136 | + return {"role": "assistant", "content": t} |
| 137 | + |
| 138 | + mc = MicroCompactor(config=MicroCompactorConfig( |
| 139 | + tool_result_budget_tokens=60, keep_recent_groups=1, time_based_enabled=False)) |
| 140 | + msgs = [ |
| 141 | + a("a0"), tu("read_file", "old"), tr("read_file", "OLD-" + "x" * 200, "old"), |
| 142 | + a("a1"), tu("read_file", "new"), tr("read_file", "NEW-" + "y" * 200, "new"), |
| 143 | + a("a2"), tu("read_file", "prot"), tr("read_file", "P-" + "z" * 200, "prot"), |
| 144 | + ] |
| 145 | + result, stats = mc.compact(msgs) |
| 146 | + bodies = [str(m.get("content", "")) for m in result] |
| 147 | + assert stats.reason == "budget_exceeded" |
| 148 | + assert any("NEW-" in b for b in bodies) # newer kept |
| 149 | + assert not any("OLD-" in b for b in bodies) # oldest trimmed (was reversed before fix) |
| 150 | + |
| 151 | + |
| 152 | +# --------------------------------------------------------------------------- |
| 153 | +# Round 5: model switch updates context window + warning level |
| 154 | +# --------------------------------------------------------------------------- |
| 155 | + |
| 156 | + |
| 157 | +@pytest.mark.parametrize("model,window", [ |
| 158 | + ("claude-opus-4-6", 200_000), |
| 159 | + ("gpt-4o", 128_000), |
| 160 | + ("deepseek-chat", 128_000), |
| 161 | + ("gemini-2.5-pro", 1_048_576), |
| 162 | +]) |
| 163 | +def test_round5_model_switch_context_window(model, window): |
| 164 | + cm = ContextManager(model="claude-sonnet-4-6") |
| 165 | + cm.update_model(model) |
| 166 | + assert cm.context_window == window |
| 167 | + # compute_context_stats should not crash and reflect the window |
| 168 | + stats = compute_context_stats([{"role": "user", "content": "x" * 600_000}], model) |
| 169 | + assert stats["context_window"] == window |
| 170 | + assert 0.0 <= stats["utilization"] <= 1.0 |
| 171 | + |
| 172 | + |
| 173 | +# --------------------------------------------------------------------------- |
| 174 | +# Round 6: prompt build with malformed MCP / None permission (no crash) |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | + |
| 177 | + |
| 178 | +def test_round6_prompt_builder_robust_to_malformed_inputs(): |
| 179 | + extras = { |
| 180 | + "mcpServers": [ |
| 181 | + {"name": "broken", "status": "error"}, # missing toolCount |
| 182 | + "not-a-dict", # wholly malformed |
| 183 | + ], |
| 184 | + "skills": [{"name": "s"}], # missing description |
| 185 | + "memory_context": "", |
| 186 | + "runtime": {}, |
| 187 | + } |
| 188 | + bundle = build_system_prompt_bundle(".", ["ok", None, "x"], extras) # None in permission_summary |
| 189 | + assert isinstance(bundle.prompt, str) and bundle.prompt |
| 190 | + assert "ok" in bundle.prompt |
| 191 | + |
| 192 | + |
| 193 | +# --------------------------------------------------------------------------- |
| 194 | +# Round 7: ToolResultBudgetManager with None + large content (no crash) |
| 195 | +# --------------------------------------------------------------------------- |
| 196 | + |
| 197 | + |
| 198 | +def test_round7_tool_result_budget_none_and_large(tmp_path): |
| 199 | + mgr = ToolResultBudgetManager(workspace=str(tmp_path), persist_threshold=1000) |
| 200 | + msgs = [ |
| 201 | + {"role": "tool_result", "toolName": "run_command", "content": None, "toolUseId": "n"}, |
| 202 | + {"role": "tool_result", "toolName": "read_file", "content": "Z" * 6000, "toolUseId": "l"}, |
| 203 | + {"role": "tool_result", "toolName": "grep_files", "content": ["a", "b"], "toolUseId": "x"}, |
| 204 | + ] |
| 205 | + modified, saved = mgr.check_and_replace(msgs) # must not raise |
| 206 | + assert modified[0]["content"] == "" # None coerced |
| 207 | + assert saved > 0 # large persisted |
| 208 | + assert "[Tool result persisted to disk" in modified[1]["content"] |
0 commit comments