Skip to content

Commit 07edbef

Browse files
QUSETIONSclaude
andcommitted
test: integration rounds covering the bug-fix batch end-to-end
7 integration scenarios exercising the fixed subsystems together: multi-turn write/read + session resume; MCP echo (spawn/protocol); memory with None-content entry; microcompactor budget trims-oldest; model switch context window; prompt build with malformed MCP/None permission; ToolResultBudgetManager None + large content. Verified stable across 3 consecutive runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b2f6720 commit 07edbef

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

tests/test_integration_rounds.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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

Comments
 (0)