Files
opensquilla--opensquilla/tests/test_skills/test_meta_stack_trace_investigator.py
2026-07-13 13:12:33 +08:00

380 lines
14 KiB
Python

"""Tests for meta-stack-trace-investigator (Round-2 fan-out + fan-in).
Single parse → parallel investigations (repo grep + GH issues + git log +
skill-context checks + memory) → fan-in root-cause synthesis → persist.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from opensquilla.engine.types import AgentEvent, DoneEvent, TextDeltaEvent
from opensquilla.skills.loader import SkillLoader
from opensquilla.skills.meta.orchestrator import MetaOrchestrator
from opensquilla.skills.meta.parser import parse_meta_plan
from opensquilla.skills.meta.types import MetaMatch
_BUNDLED = (
Path(__file__).resolve().parents[2] / "src" / "opensquilla" / "skills" / "bundled"
)
_EXP = Path(__file__).resolve().parents[2] / "src" / "opensquilla" / "skills" / "exp"
def _bundle_loader(tmp_path: Path) -> SkillLoader:
loader = SkillLoader(
bundled_dir=_BUNDLED,
extra_dirs=[_EXP],
snapshot_path=tmp_path / "snap.json",
)
loader.invalidate_cache()
loader.load_all()
return loader
def test_parses_with_expected_topology(tmp_path: Path) -> None:
loader = _bundle_loader(tmp_path)
spec = loader.get_by_name("meta-stack-trace-investigator")
assert spec is not None
plan = parse_meta_plan(spec)
assert plan is not None
step_ids = [s.id for s in plan.steps]
# trace_collect extracts the investigation brief from the same turn so a
# pasted traceback does not pause for a confirmation form. Newly-added
# *_degraded fallback siblings
# (grep_repo_degraded, search_issues_degraded, git_history_degraded,
# memory_recall_degraded) make the previous evidence-source order
# explicit; they were present in the SKILL.md before this migration
# but missing from this list.
assert step_ids == [
"trace_collect",
"parse_trace",
"grep_repo",
"grep_repo_degraded",
"search_issues",
"search_issues_degraded",
"git_history",
"git_history_degraded",
"diff_context",
"diff_context_degraded",
"history_patterns",
"history_patterns_degraded",
"memory_recall",
"memory_recall_degraded",
"language_probe",
"root_cause",
"repro_suggestion",
"degraded_summary",
"persist",
]
by_id = {s.id: s for s in plan.steps}
assert by_id["trace_collect"].kind == "llm_chat"
assert by_id["parse_trace"].depends_on == ("trace_collect",)
assert by_id["diff_context"].on_failure == "diff_context_degraded"
assert by_id["diff_context_degraded"].depends_on == ()
assert by_id["history_patterns"].on_failure == "history_patterns_degraded"
assert by_id["history_patterns_degraded"].depends_on == ()
# Investigations each fan out from parse_trace (parallel).
for inv in (
"grep_repo",
"search_issues",
"git_history",
"diff_context",
"history_patterns",
"memory_recall",
"language_probe",
):
assert by_id[inv].depends_on == ("parse_trace",), (
f"investigation {inv} should fan out only from parse_trace"
)
# root_cause gathers all investigations.
assert set(by_id["root_cause"].depends_on) == {
"grep_repo",
"search_issues",
"git_history",
"diff_context",
"history_patterns",
"memory_recall",
"language_probe",
}
assert by_id["repro_suggestion"].depends_on == ("root_cause",)
assert set(by_id["degraded_summary"].depends_on) == {
"grep_repo",
"search_issues",
"git_history",
"diff_context",
"history_patterns",
"memory_recall",
"language_probe",
"repro_suggestion",
}
assert by_id["persist"].depends_on == ("degraded_summary",)
assert plan.final_text_mode == "step:degraded_summary"
def test_language_probe_routes_to_language_specific_skills(tmp_path: Path) -> None:
loader = _bundle_loader(tmp_path)
spec = loader.get_by_name("meta-stack-trace-investigator")
assert spec is not None
plan = parse_meta_plan(spec)
assert plan is not None
by_id = {s.id: s for s in plan.steps}
probe = by_id["language_probe"]
assert probe.kind == "agent"
assert probe.skill == "stack-trace-generic-probe"
assert probe.depends_on == ("parse_trace",)
assert [(case.when, case.to) for case in probe.route] == [
(
"'\"language\":\"python\"' in outputs.parse_trace or "
"'\"language\": \"python\"' in outputs.parse_trace or "
"'LANGUAGE: python' in outputs.trace_collect",
"stack-trace-python-probe",
),
(
"'\"language\":\"javascript\"' in outputs.parse_trace or "
"'\"language\": \"javascript\"' in outputs.parse_trace or "
"'\"language\":\"typescript\"' in outputs.parse_trace or "
"'\"language\": \"typescript\"' in outputs.parse_trace or "
"'LANGUAGE: javascript' in outputs.trace_collect or "
"'LANGUAGE: typescript' in outputs.trace_collect",
"stack-trace-js-probe",
),
(
"'\"language\":\"go\"' in outputs.parse_trace or "
"'\"language\": \"go\"' in outputs.parse_trace or "
"'LANGUAGE: go' in outputs.trace_collect",
"stack-trace-go-probe",
),
(
"'\"language\":\"rust\"' in outputs.parse_trace or "
"'\"language\": \"rust\"' in outputs.parse_trace or "
"'LANGUAGE: rust' in outputs.trace_collect",
"stack-trace-rust-probe",
),
]
for name in {
"stack-trace-generic-probe",
"stack-trace-python-probe",
"stack-trace-js-probe",
"stack-trace-go-probe",
"stack-trace-rust-probe",
}:
routed_spec = loader.get_by_name(name)
assert routed_spec is not None, f"missing routed skill {name}"
assert routed_spec.kind == "skill"
def _classify(system: str, user_message: str) -> str:
if "Extract a compact investigation brief" in user_message:
return "trace_collect"
if "trace parser" in user_message:
return "parse_trace"
if "Classify the stack trace language" in user_message:
return "classify_language"
if "Search the current working-directory repository" in user_message:
return "grep_repo"
if "Search this project's GitHub repository" in user_message:
return "search_issues"
if "List recent commits" in user_message:
return "git_history"
if "Tool: exec_command" in user_message and "parse_tool_result|run_step" in user_message:
return "grep_repo"
if "Tool: exec_command" in user_message and "gh issue list" in user_message:
return "search_issues"
if "Tool: exec_command" in user_message and "git log" in user_message:
return "git_history"
if "Tool: memory_search" in user_message:
return "memory_recall"
if "Tool: memory_save" in user_message:
return "persist"
if "Run a language-specific stack-trace probe" in user_message:
return "language_probe"
# memory skill (action=search or action=save): runs as agent skill.
# The first memory step uses action=search (memory_recall), the second
# uses action=save (persist). Distinguish by content presence.
if "action: save" in user_message or "action=save" in user_message:
return "persist"
if "Synthesize a root-cause hypothesis" in user_message:
return "root_cause"
if "Propose the smallest safe verification" in user_message:
return "repro_suggestion"
if "Produce the final user-facing investigation" in user_message:
return "degraded_summary"
if "action: search" in user_message or "action=search" in user_message:
return "memory_recall"
# Memory skill sub-Agent could also come in via its system prompt; just
# treat any unmatched call as "memory" (returns canned recall).
if "memory" in (system or "").lower():
return "memory_recall"
return "other"
@pytest.mark.asyncio
async def test_happy_path_synthesizes_root_cause(tmp_path: Path) -> None:
loader = _bundle_loader(tmp_path)
spec = loader.get_by_name("meta-stack-trace-investigator")
plan = parse_meta_plan(spec)
assert plan is not None
canned_parse = (
'{"exception_class":"AttributeError",'
'"exception_message":"NoneType has no attribute foo",'
'"primary_file":"src/opensquilla/engine/agent.py",'
'"primary_line":1234,'
'"symbols":["_run_one_streaming","handle_tool"]}'
)
async def runner(_system: str, user_msg: str) -> AsyncIterator[AgentEvent]:
which = _classify(_system, user_msg)
if which == "trace_collect":
yield TextDeltaEvent(
text=(
"LANGUAGE: python\n"
"EXPECTED_BEHAVIOR: ASSUMED: not provided\n"
"RECENT_CHANGES: ASSUMED: not provided\n"
"TRACE_PRESENT: yes\n"
"PRIMARY_EXCEPTION: AttributeError\n"
"PRIMARY_FILES:\n"
" - src/opensquilla/engine/agent.py:1234"
)
)
elif which == "classify_language":
yield TextDeltaEvent(
text='{"language":"python","runtime":"cpython","confidence":"high"}'
)
elif which == "parse_trace":
yield TextDeltaEvent(text=canned_parse)
elif which == "grep_repo":
yield TextDeltaEvent(
text="src/opensquilla/engine/agent.py:1230: def _run_one_streaming(...)",
)
elif which == "search_issues":
yield TextDeltaEvent(text="#42 AttributeError in agent loop (closed)")
elif which == "git_history":
yield TextDeltaEvent(text="a3f7c2 2026-05-20 fix: stream agent events")
elif which == "memory_recall":
yield TextDeltaEvent(text="NO_PRIOR_INCIDENTS")
elif which == "language_probe":
yield TextDeltaEvent(
text=(
"LANGUAGE_PROBE: python\n"
"CHECKS:\n - KeyError/None contract\n"
"VERIFY:\n - python -m pytest tests/test_agent.py -k tool"
)
)
elif which == "root_cause":
yield TextDeltaEvent(
text=(
"ROOT_CAUSE: handler returned None for some branch\n"
"EVIDENCE:\n - grep: agent.py:1230\n"
"SUGGESTIONS:\n - agent.py:1234 — guard None return"
),
)
elif which == "repro_suggestion":
yield TextDeltaEvent(
text=(
"CONFIDENCE: high\n"
"VERIFY:\n - python -m pytest tests/test_agent.py -k tool\n"
"FIX_FIRST:\n - src/opensquilla/engine/agent.py: guard None return"
)
)
elif which == "degraded_summary":
yield TextDeltaEvent(
text=(
"## Diagnosis\nhandler returned None\n"
"## Evidence Status\nrepo evidence present\n"
"## Verification Commands\npython -m pytest tests/test_agent.py"
)
)
else:
yield TextDeltaEvent(text="memory record saved")
yield DoneEvent(text="")
orch = MetaOrchestrator(agent_runner=runner, skill_loader=loader)
result = await orch.run(
MetaMatch(
plan=plan,
inputs={
"user_message": (
"investigate stack trace:\n"
"Traceback (most recent call last):\n"
" File \"src/opensquilla/engine/agent.py\", line 1234, in foo\n"
"AttributeError: 'NoneType' object has no attribute 'foo'"
),
},
),
)
assert result.ok, f"plan failed: {result.error}"
assert "ROOT_CAUSE" in result.step_outputs["root_cause"]
assert "AttributeError" in result.step_outputs["parse_trace"]
assert result.final_text.startswith("## Diagnosis")
assert "memory record saved" not in result.final_text
@pytest.mark.asyncio
async def test_root_cause_fans_in_parallel_investigations(tmp_path: Path) -> None:
"""Verify root_cause prompt embeds output from all 4 investigations
by checking the rendered task body contains each upstream marker."""
loader = _bundle_loader(tmp_path)
spec = loader.get_by_name("meta-stack-trace-investigator")
plan = parse_meta_plan(spec)
assert plan is not None
captured_root_cause_prompt: dict[str, str] = {}
async def runner(_system: str, user_msg: str) -> AsyncIterator[AgentEvent]:
which = _classify(_system, user_msg)
if which == "trace_collect":
yield TextDeltaEvent(text="LANGUAGE: python\nPRIMARY_EXCEPTION: RuntimeError")
elif which == "parse_trace":
yield TextDeltaEvent(text="<<PARSE_RESULT>>")
elif which == "classify_language":
yield TextDeltaEvent(text="<<CLASSIFY_RESULT>>")
elif which == "grep_repo":
yield TextDeltaEvent(text="<<GREP_HIT>>")
elif which == "search_issues":
yield TextDeltaEvent(text="<<ISSUE_HIT>>")
elif which == "git_history":
yield TextDeltaEvent(text="<<COMMIT_HIT>>")
elif which == "memory_recall":
yield TextDeltaEvent(text="<<MEMORY_HIT>>")
elif which == "language_probe":
yield TextDeltaEvent(text="<<LANGUAGE_PROBE>>")
elif which == "root_cause":
captured_root_cause_prompt["body"] = user_msg
yield TextDeltaEvent(text="ROOT_CAUSE: ok")
elif which == "repro_suggestion":
yield TextDeltaEvent(text="CONFIDENCE: medium")
elif which == "degraded_summary":
yield TextDeltaEvent(text="## Diagnosis\nok")
else:
yield TextDeltaEvent(text="saved")
yield DoneEvent(text="")
orch = MetaOrchestrator(agent_runner=runner, skill_loader=loader)
result = await orch.run(
MetaMatch(
plan=plan,
inputs={
"user_message": "investigate stack trace",
},
),
)
assert result.ok
body = captured_root_cause_prompt["body"]
# Fan-in evidence: each upstream sentinel is embedded.
for sentinel in (
"<<PARSE_RESULT>>",
"<<GREP_HIT>>",
"<<ISSUE_HIT>>",
"<<COMMIT_HIT>>",
"<<MEMORY_HIT>>",
"<<LANGUAGE_PROBE>>",
):
assert sentinel in body, f"root_cause prompt missing upstream {sentinel}"