Files
Masterarbeit/.claude/skills/run-experiment/test_opencode_adapter.py
T
Christoph SchwörerandClaude Opus 5 6c5c26a2e4 Lokale Matrix liefert Ergebnisse: 119 Anforderungen aus Gemma und Qwen
Skill 13.0.0/13.1.0, Adapter 2.5.2.

Spiegel-Arbeitsverzeichnis: OpenCode laeuft nicht mehr direkt im
Codebasis-Root, sondern in einem Verzeichnis aus Junctions auf die
Top-Level-Eintraege, mit einem echten Ergebnisverzeichnis, dessen Inhalt
nach dem Lauf uebernommen wird. Damit landen relative wie absolute
Ausgabepfade am richtigen Ort, ohne dass der Prompt vom Wortlaut der
Claude-Laeufe abweichen muss. Der Spiegel liegt in _meta des Laufs; die
Codebasis bleibt unberuehrt.

Der Spiegel allein genuegte nicht. Sechs Laeufe schrieben mit korrektem
absolutem Pfad und wurden dennoch abgewiesen, weil OpenCode Ziele im
Repository gegen den worktree-relativen Pfad abgleicht - ein Befund, der
seit Skill 10.0.2 dokumentiert war und erst sichtbar wurde, als der
Spiegel vom Temp-Verzeichnis ins Projekt wanderte.

analyse-anforderungen.py erkennt Kennungen als Markdown-Ueberschrift, auch
ohne Feldnamen, sofern die Pflichtfelder folgen. Regressionsprobe an fuenf
Claude-Laeufen unveraendert.

Ergebnisse der gueltigen Laeufe: Qwen 3.5-9B liefert 107 der 119
Anforderungen, davon 75 im Modus custom mit nur drei Subagenten. Gemma
kommt auf 12. Der Modus builtin fiel bei beiden Modellen aus - drei von
drei Laeufen endeten nach einem Turn ohne einen Werkzeugaufruf. Die 13
Laeufe mit Adapter 2.3.0 bis 2.5.1 sind Artefakte der Fehlersuche und
als adapterbedingte Fehlmessungen gekennzeichnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:49:53 +02:00

448 lines
18 KiB
Python

import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
ADAPTER_PATH = Path(__file__).with_name("opencode-adapter.py")
SPEC = importlib.util.spec_from_file_location("opencode_adapter", ADAPTER_PATH)
ADAPTER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ADAPTER)
class OpenCodeAdapterTests(unittest.TestCase):
def test_model_reference_keeps_upstream_slashes(self):
self.assertEqual(
(
"tensorx/qwen/qwen3.8-flash-next",
"qwen/qwen3.8-flash-next",
),
ADAPTER.normalize_model("qwen/qwen3.8-flash-next"),
)
self.assertEqual(
(
"tensorx/qwen/qwen3.8-flash-next",
"qwen/qwen3.8-flash-next",
),
ADAPTER.normalize_model("tensorx/qwen/qwen3.8-flash-next"),
)
def test_custom_mode_translates_agents_and_restricts_delegation(self):
base = {
"provider": {
"tensorx": {
"models": {"qwen/qwen3.8-flash-next": {"name": "Qwen"}}
}
}
}
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
root = temp / "root"
output = root / "run" / "Ergebnisse"
root.mkdir()
output.mkdir(parents=True)
agents = temp / "agents.json"
agents.write_text(
json.dumps(
{
"reviewer": {
"description": "Prueft Fakten",
"prompt": "Pruefe nur die zugewiesenen Fakten.",
}
}
),
encoding="utf-8",
)
config = ADAPTER.build_run_config(
base,
"tensorx/qwen/qwen3.8-flash-next",
"qwen/qwen3.8-flash-next",
"custom",
root,
output,
agents,
)
self.assertEqual("allow", config["permission"]["task"]["reviewer"])
self.assertEqual("deny", config["permission"]["task"]["*"])
self.assertEqual("subagent", config["agent"]["reviewer"]["mode"])
self.assertEqual(
"tensorx/qwen/qwen3.8-flash-next",
config["agent"]["reviewer"]["model"],
)
self.assertEqual("deny", config["agent"]["reviewer"]["permission"]["task"])
self.assertIn(
ADAPTER.normalized_path(output) + "/**",
config["permission"]["edit"],
)
self.assertIn(
"run/Ergebnisse/**",
config["permission"]["edit"],
)
def test_output_permissions_include_parent_relative_worktree_path(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
root = temp / "QuellCode" / "Product"
output = temp / "Versuche" / "Lauf" / "Ergebnisse"
root.mkdir(parents=True)
output.mkdir(parents=True)
patterns = ADAPTER.output_permission_patterns(root, output, temp)
self.assertIn("Versuche/Lauf/Ergebnisse/**", patterns)
self.assertIn("../../Versuche/Lauf/Ergebnisse", patterns)
self.assertIn("../../Versuche/Lauf/Ergebnisse/**", patterns)
self.assertIn(ADAPTER.normalized_path(output) + "/**", patterns)
def test_normalize_result_uses_exported_metrics_and_tool_calls(self):
session = {
"info": {
"id": "ses_test",
"version": "1.18.25",
"model": {
"id": "qwen/qwen3.8-flash-next",
"providerID": "tensorx",
},
"tokens": {
"input": 100,
"output": 10,
"reasoning": 5,
"cache": {"read": 20, "write": 0},
},
"cost": 0.1,
},
"messages": [
{
"info": {"role": "assistant", "finish": "stop"},
"parts": [
{
"type": "tool",
"tool": "task",
"state": {
"status": "completed",
"input": {
"subagent_type": "reviewer",
"description": "Pruefen",
},
},
},
{"type": "text", "text": "Fertig"},
],
}
],
}
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir)
(output / "StRS.md").write_text("Inhalt", encoding="utf-8")
result = ADAPTER.normalize_result(
session=session,
events=[],
model_ref="tensorx/qwen/qwen3.8-flash-next",
mode="custom",
effort="low",
exit_code=0,
timed_out=False,
interrupted=False,
duration_s=1.5,
output_dir=output,
errors=[],
)
self.assertFalse(result["is_error"])
self.assertEqual(135, result["usage"]["total_tokens"])
self.assertEqual(1, result["tool_call_count"])
self.assertEqual(1, result["subagent_stats"]["spawned"])
self.assertEqual({"reviewer": 1}, result["subagent_stats"]["by_type"])
self.assertEqual("Fertig", result["result"])
self.assertEqual(1, len(result["written_files"]))
class ReadonlyShellTests(unittest.TestCase):
"""Denylist: alles erlaubt ausser schreibenden und bauenden Kommandos."""
def test_catch_all_allow_comes_first(self):
# OpenCode wertet der Reihe nach aus, die letzte passende Regel gewinnt.
# Stuende das Catch-all hinten, waere jede Sperre wirkungslos.
perms = ADAPTER.readonly_shell_permissions()
self.assertEqual("allow", perms["*"])
self.assertEqual("*", next(iter(perms)))
def test_writing_and_building_commands_are_denied(self):
perms = ADAPTER.readonly_shell_permissions()
for muster in ("rm *", "mv *", "sed -i*", "git commit*", "git push*",
"dotnet *", "msbuild *", "npm install*",
"Remove-Item *", "Set-Content *", "Out-File *"):
self.assertEqual("deny", perms[muster], muster)
def test_reading_commands_need_no_rule(self):
# Der Kern der Umstellung: Lesekommandos - auch als Pipeline - sind
# erlaubt, ohne einzeln aufgefuehrt zu sein. Genau daran scheiterte
# 'Get-ChildItem ... | Format-Table ...' unter der Allowlist.
perms = ADAPTER.readonly_shell_permissions()
for kommando in ("ls -la", "cat foo.cs", "rg muster",
"Get-ChildItem -Recurse | Format-Table Name",
"find . -name *.cs"):
self.assertNotIn(kommando, perms)
self.assertEqual("allow", perms["*"])
def test_no_reading_command_is_denied(self):
verboten_praefixe = ("ls", "cat", "rg", "grep", "find", "head", "tail",
"Get-ChildItem", "Get-Content", "Select-String",
"git status", "git log", "dir", "type")
for muster, aktion in ADAPTER.readonly_shell_permissions().items():
if aktion != "deny":
continue
for praefix in verboten_praefixe:
self.assertFalse(
muster.lower().startswith(praefix.lower()),
f"Lesekommando gesperrt: {muster}",
)
class SpiegelTests(unittest.TestCase):
"""Der Spiegel loest relative Ausgabepfade, ohne die Quelle zu beruehren."""
def test_relativer_schreibpfad_landet_im_laufverzeichnis(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
quelle = temp / "codebasis"
(quelle / "src").mkdir(parents=True)
(quelle / "src" / "a.cs").write_text("Quellcode", encoding="utf-8")
(quelle / "README.md").write_text("Liesmich", encoding="utf-8")
ausgabe = temp / "lauf" / "Ergebnisse"
ausgabe.mkdir(parents=True)
spiegel = temp / "spiegel"
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
try:
self.assertTrue((spiegel / "src" / "a.cs").is_file())
self.assertTrue((spiegel / "README.md").is_file())
# `Ergebnisse` ist im Spiegel ein echtes Verzeichnis - ein
# relativer Schreibpfad landet dort und wird danach uebernommen.
(spiegel / "Ergebnisse" / "StRS.md").write_text("X", encoding="utf-8")
self.assertFalse((ausgabe / "StRS.md").is_file())
anzahl = ADAPTER.uebernimm_ergebnisse(spiegel, ausgabe, lambda _: None)
self.assertEqual(1, anzahl)
self.assertTrue((ausgabe / "StRS.md").is_file())
self.assertEqual("X", (ausgabe / "StRS.md").read_text(encoding="utf-8"))
finally:
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
# Der Abbau darf die Quelle nicht durch die Junction hindurch loeschen.
self.assertFalse(spiegel.exists())
self.assertTrue((quelle / "src" / "a.cs").is_file())
self.assertEqual("Quellcode", (quelle / "src" / "a.cs").read_text(encoding="utf-8"))
self.assertTrue((ausgabe / "StRS.md").is_file())
def test_freigabe_enthaelt_arbeitsverzeichnis_relative_form(self):
"""resolve() folgt der Junction - die relative Form darf nicht verloren gehen."""
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
quelle = temp / "codebasis"
(quelle / "src").mkdir(parents=True)
ausgabe = temp / "lauf" / "Ergebnisse"
ausgabe.mkdir(parents=True)
spiegel = temp / "spiegel"
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
try:
config = ADAPTER.build_run_config(
{}, "lmstudio/x", "x", "solo", spiegel, ausgabe, None,
provider="lmstudio",
zusatz_ausgaben=[spiegel / ausgabe.name],
)
finally:
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
erlaubt = [k for k, v in config["permission"]["edit"].items() if v == "allow"]
self.assertIn("Ergebnisse", erlaubt)
self.assertIn("Ergebnisse/**", erlaubt)
def test_quelle_bleibt_ohne_neue_eintraege(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
quelle = temp / "codebasis"
(quelle / "src").mkdir(parents=True)
vorher = sorted(p.name for p in quelle.iterdir())
ausgabe = temp / "lauf" / "Ergebnisse"
ausgabe.mkdir(parents=True)
spiegel = temp / "spiegel"
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
(spiegel / "Ergebnisse" / "datei.md").write_text("X", encoding="utf-8")
ADAPTER.uebernimm_ergebnisse(spiegel, ausgabe, lambda _: None)
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
self.assertEqual(vorher, sorted(p.name for p in quelle.iterdir()))
class UsageCapturedTests(unittest.TestCase):
"""Eine Null aus einem laufenden Subagenten ist kein Messwert."""
def _result(self, session, output):
return ADAPTER.normalize_result(
session=session, events=[], model_ref="lmstudio/google/gemma-4-e4b",
mode="builtin", effort="high", exit_code=1, timed_out=True,
interrupted=False, duration_s=3600.0, output_dir=output, errors=[],
provider="lmstudio", effort_applied=False,
)
def test_running_subagent_with_zero_tokens_is_not_captured(self):
session = {
"info": {"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 0, "output": 0, "reasoning": 0,
"cache": {"read": 0, "write": 0}}},
"messages": [{
"info": {"role": "assistant"},
"parts": [{"type": "tool", "tool": "task",
"state": {"status": "running",
"input": {"subagent_type": "explore"}}}],
}],
}
with tempfile.TemporaryDirectory() as temp_dir:
result = self._result(session, Path(temp_dir))
self.assertFalse(result["usage_captured"])
self.assertIn("nicht erfasst", result["usage_note"])
self.assertIn("1 Subagent(en)", result["usage_note"])
def test_real_token_counts_are_marked_captured(self):
session = {
"info": {"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 100, "output": 10, "reasoning": 0,
"cache": {"read": 0, "write": 0}}},
"messages": [{"info": {"role": "assistant"},
"parts": [{"type": "text", "text": "ok"}]}],
}
with tempfile.TemporaryDirectory() as temp_dir:
result = self._result(session, Path(temp_dir))
self.assertTrue(result["usage_captured"])
self.assertNotIn("usage_note", result)
class OpenCodeLmStudioTests(unittest.TestCase):
def test_model_reference_uses_lmstudio_provider(self):
self.assertEqual(
("lmstudio/google/gemma-4-e4b", "google/gemma-4-e4b"),
ADAPTER.normalize_model("google/gemma-4-e4b", "lmstudio"),
)
self.assertEqual(
("lmstudio/qwen/qwen3.5-9b", "qwen/qwen3.5-9b"),
ADAPTER.normalize_model("lmstudio/qwen/qwen3.5-9b", "lmstudio"),
)
def test_template_declares_both_local_models_without_key(self):
template = json.loads(
(ADAPTER_PATH.parent / "opencode-lmstudio.json").read_text(
encoding="utf-8-sig"
)
)
provider = template["provider"]["lmstudio"]
self.assertEqual(
"http://localhost:1234/v1", provider["options"]["baseURL"]
)
self.assertEqual(
{"google/gemma-4-e4b", "qwen/qwen3.5-9b"},
set(provider["models"]),
)
# Lokale Server pruefen den Key nicht; er darf nur ein Platzhalter sein.
self.assertEqual("lm-studio", provider["options"]["apiKey"])
def test_build_run_config_pins_loaded_context_window(self):
base = json.loads(
(ADAPTER_PATH.parent / "opencode-lmstudio.json").read_text(
encoding="utf-8-sig"
)
)
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) / "root"
output = root / "run" / "Ergebnisse"
output.mkdir(parents=True)
config = ADAPTER.build_run_config(
base,
"lmstudio/google/gemma-4-e4b",
"google/gemma-4-e4b",
"solo",
root,
output,
None,
provider="lmstudio",
context_limit=32768,
)
model = config["provider"]["lmstudio"]["models"]["google/gemma-4-e4b"]
self.assertEqual(32768, model["limit"]["context"])
self.assertEqual("deny", config["permission"]["task"])
self.assertEqual("deny", config["permission"]["webfetch"])
def test_normalize_result_reports_local_runtime_and_zero_cost(self):
runtime = {
"provider": "lmstudio",
"quantization": "Q4_K_M",
"arch": "gemma4",
"compatibility_type": "gguf",
"loaded_context_length": 32768,
"max_context_length": 131072,
}
session = {
"info": {
"id": "ses_local",
"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 10, "output": 4, "reasoning": 2,
"cache": {"read": 0, "write": 0}},
"cost": 0,
},
"messages": [
{
"info": {"role": "assistant", "finish": "stop"},
"parts": [{"type": "text", "text": "Fertig"}],
}
],
}
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir)
(output / "StRS.md").write_text("Inhalt", encoding="utf-8")
result = ADAPTER.normalize_result(
session=session,
events=[],
model_ref="lmstudio/google/gemma-4-e4b",
mode="solo",
effort="high",
exit_code=0,
timed_out=False,
interrupted=False,
duration_s=2.0,
output_dir=output,
errors=[],
provider="lmstudio",
effort_applied=False,
local_runtime=runtime,
)
self.assertEqual("lmstudio", result["provider"])
self.assertEqual("opencode-lmstudio", result["adapter"])
self.assertFalse(result["effort_applied"])
self.assertEqual(32768, result["context_window"])
self.assertEqual("Q4_K_M", result["local_runtime"]["quantization"])
self.assertEqual(0, result["cost"])
self.assertIn("lokaler Betrieb", result["cost_source"])
self.assertEqual(16, result["usage"]["total_tokens"])
def test_tensorx_result_keeps_remote_shape(self):
session = {"info": {"model": {"id": "qwen/qwen3.8-flash-next"},
"tokens": {"input": 1, "output": 1}}, "messages": []}
result = ADAPTER.normalize_result(
session=session, events=[], model_ref="tensorx/qwen/qwen3.8-flash-next",
mode="solo", effort="low", exit_code=0, timed_out=False,
interrupted=False, duration_s=1.0, output_dir=Path("."), errors=[],
)
self.assertEqual("tensorx", result["provider"])
self.assertEqual("opencode-tensorx", result["adapter"])
self.assertTrue(result["effort_applied"])
self.assertNotIn("local_runtime", result)
self.assertNotIn("context_window", result)
if __name__ == "__main__":
unittest.main()