File size: 11,623 Bytes
8edee29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
"""
agents/test_generator.py
------------------------
Test Generator Agent for AutoDevAgent.

Responsible for producing a test suite for the generated code after
it has executed successfully. This closes the loop β€” code that runs
is not necessarily correct, but code that passes tests is much more
likely to be.

Design:
  - Python tasks: generates standard unittest test cases that import
    and call the generated code directly. Tests cover the happy path,
    edge cases, and type correctness.
  - SQL tasks: generates Python assertions that run additional queries
    against the same SQLite schema to verify row counts, column
    presence, value ranges, and null checks.
  - Uses the primary model (70B) β€” test quality matters as much as
    code quality.
  - Returns raw test code as a string β€” the TestRunner executes it
    separately so failures re-engage the debug loop.

Usage:
    from agents.test_generator import TestGeneratorAgent
    from pipeline.state import PipelineState, Language

    agent = TestGeneratorAgent()
    # state.generated_code and state.execution_result must be set
    updated = agent.run(state)
    print(updated["generated_tests"])
"""

import logging
from typing import Any

from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage, HumanMessage

from config import settings
from pipeline.state import (
    PipelineState,
    PipelineStatus,
    Language,
    SQLSchema,
)

logger = logging.getLogger(__name__)


# ------------------------------------------------------------------ #
#  Prompts                                                             #
# ------------------------------------------------------------------ #

PYTHON_TEST_SYSTEM = """
You are an expert Python test engineer.
You will be given a task description, the working code, and its output.
Write a unittest test suite that verifies the code is correct.

Rules:
- Output ONLY raw Python test code. No markdown, no explanation, no code fences.
- Use the standard unittest module β€” no pytest, no third-party libs.
- The test file must be self-contained and runnable as-is with: python -m unittest test_file.py
- Copy the function(s) being tested directly into the test file so it is standalone.
  Do NOT use import statements to import from external files.
- Write at least 3 test cases covering:
    1. A standard happy-path input
    2. An edge case (empty string, zero, single element, etc.)
    3. A boundary or negative test (e.g. inputs that should return False or 0)
- IMPORTANT β€” Do NOT copy examples from the code: The "Output when run" section shows one
  example execution. Do NOT simply copy those same input values into your test assertions.
  Your test inputs must be DIFFERENT values that you independently derive from the function's
  logic. Re-testing the exact same example adds no value and does not verify correctness.
  Example: if the code prints "fibonacci(10) = 55", do NOT write assertEqual(fibonacci(10), 55).
  Instead write assertEqual(fibonacci(7), 13) or assertEqual(fibonacci(0), 0) etc.
- IMPORTANT β€” None inputs: ONLY test None inputs if the task description explicitly mentions
  handling None or null values. For string/list tasks that don't mention None, do NOT write
  tests that pass None β€” they will cause TypeError errors in functions that don't handle it.
  Example: "check if two strings are anagrams" β†’ do NOT test are_anagrams(None, "hello")
  Example: "handle None gracefully and return False" β†’ DO test func(None)
- Every test method name must start with test_
- IMPORTANT: Every test method MUST have a one-sentence docstring that explains exactly
  what input is used and what the expected outcome is. Be specific β€” mention the actual
  values, not just "tests the happy path". Example:
      def test_empty_list(self):
          \"\"\"Verifies that passing an empty list returns 0 because there are no elements to sum.\"\"\"
- End the file with:
    if __name__ == "__main__":
        unittest.main(verbosity=2)
""".strip()

SQL_TEST_SYSTEM = """
You are an expert SQL test engineer working with SQLite.
You will be given:
  - The original SQL task
  - The SQL query that was written
  - The database schema (CREATE TABLE + dummy data)
  - The query output

Write Python assertion tests that verify the query is correct.
The tests will run the query again against the same SQLite schema and assert on the results.

Rules:
- Output ONLY raw Python code. No markdown, no explanation, no code fences.
- Use Python's built-in sqlite3 and unittest modules only.
- Re-create the schema and dummy data inside the test setUp() method.
- Always access result values by POSITION (row[0], row[1]) NOT by column name string.
  SQLite returns aggregate expressions as raw strings like "MAX(salary)" or "COUNT(*)"
  so column-name access is fragile and must never be used.
- Write exactly 3 assertion-based tests covering:
    1. Row count β€” the query returns the expected number of rows
    2. Not-empty check β€” the result set is non-empty and the first row exists
    3. Value check β€” at least one specific value in the results is correct,
       accessed by POSITION (row[0], row[1], etc.)
- IMPORTANT: Every test method MUST have a one-sentence docstring that explains exactly
  what is being verified and why the expected value is correct given the dummy data.
  Be specific β€” mention the actual expected values. Example:
      def test_row_count(self):
          \"\"\"Verifies the query returns exactly 2 rows because only Alice and Bob have more than 3 orders in the dummy data.\"\"\"
- The test file must be runnable as-is.
- End the file with:
    if __name__ == "__main__":
        unittest.main(verbosity=2)
""".strip()


# ------------------------------------------------------------------ #
#  Agent                                                               #
# ------------------------------------------------------------------ #

class TestGeneratorAgent:
    """
    Generates a test suite for successfully executed code.

    For Python: produces unittest test cases that call the generated
    functions directly. For SQL: produces Python assertion checks that
    re-run the query against the same SQLite schema.

    Attributes:
        llm: ChatGroq instance using the primary model (70B).
    """

    def __init__(self) -> None:
        """LLM client is built lazily in run() once model assignments are known."""
        self.llm = None

    def _build_llm(self, model: str) -> "ChatGroq":
        return ChatGroq(
            api_key=settings.groq_api_key,
            model=model,
            temperature=0.1,
            max_tokens=2000,
            request_timeout=settings.groq_request_timeout,
        )

    def run(self, state: PipelineState) -> dict[str, Any]:
        """
        Generate tests for the code in the current pipeline state.

        Builds a prompt containing the task, the working code, and
        its output. Sends to the LLM and returns the raw test code.

        Args:
            state: Current PipelineState. Reads: task, language,
                   generated_code, execution_result, sql_schema.

        Returns:
            Partial state dict with keys:
              - "generated_tests": str  β€” raw test code
              - "status":          PipelineStatus.TESTING
        """
        ma = state.model_assignments or {}
        model = ma.get("tester", settings.groq_model_primary)
        self.llm = self._build_llm(model)
        logger.info("TestGeneratorAgent using model: %s", model)

        logger.info(
            "TestGeneratorAgent running for language: %s", state.language.value
        )

        system_prompt = (
            SQL_TEST_SYSTEM
            if state.language == Language.SQL
            else PYTHON_TEST_SYSTEM
        )

        human_content = _build_human_message(state)

        messages = [
            SystemMessage(content=system_prompt),
            HumanMessage(content=human_content),
        ]

        # ── LLM call ──────────────────────────────────────────────── #
        try:
            response = self.llm.invoke(messages)
            raw      = response.content.strip()
            logger.debug(
                "TestGeneratorAgent raw response length: %d chars", len(raw)
            )
        except Exception as e:
            logger.error("TestGeneratorAgent LLM call failed: %s", e)
            raise RuntimeError(f"Test generator LLM call failed: {e}") from e

        # ── Strip markdown fences if LLM added them ───────────────── #
        tests = _strip_code_fences(raw)

        if not tests.strip():
            raise ValueError("TestGeneratorAgent returned empty test code.")

        logger.info(
            "TestGeneratorAgent produced %d lines of test code",
            len(tests.splitlines()),
        )

        # ── Track token usage ─────────────────────────────────────── #
        from observability.langsmith_tracer import extract_token_usage_from_response
        prompt_t, completion_t = extract_token_usage_from_response(response)
        updated_token_usage = state.token_usage.model_copy()
        updated_token_usage.add(prompt_t, completion_t)

        return {
            "generated_tests": tests,
            "status":          PipelineStatus.TESTING,
            "token_usage":     updated_token_usage,
        }


# ------------------------------------------------------------------ #
#  Helpers                                                             #
# ------------------------------------------------------------------ #

def _build_human_message(state: PipelineState) -> str:
    """
    Build the human message for the test generator LLM call.

    Includes the task, the working code, the execution output, and
    for SQL tasks the schema context so the LLM can recreate the DB.

    Args:
        state: Current PipelineState.

    Returns:
        Formatted prompt string.
    """
    stdout = ""
    if state.execution_result and state.execution_result.stdout:
        stdout = state.execution_result.stdout[:500]   # Cap to avoid prompt bloat

    lines: list[str] = [
        f"Task: {state.task}",
        "",
        "Working code:",
        state.generated_code,
        "",
        f"Output when run:",
        stdout if stdout else "(no stdout β€” code ran without printing)",
    ]

    # For SQL tasks include the schema so the LLM can recreate it in tests
    if state.language == Language.SQL and state.sql_schema:
        lines.append("")
        lines.append("Database schema:")
        for stmt in state.sql_schema.create_statements:
            lines.append(f"  {stmt}")
        lines.append("")
        lines.append("Dummy data:")
        for stmt in state.sql_schema.insert_statements:
            lines.append(f"  {stmt}")

    lines.append("")
    lines.append("Write the test suite now:")
    return "\n".join(lines)


def _strip_code_fences(raw: str) -> str:
    """
    Remove markdown code fences from LLM output.

    Args:
        raw: Raw string from the LLM response.

    Returns:
        Clean code string with fences removed.
    """
    stripped = raw.strip()
    if stripped.startswith("```"):
        lines = stripped.splitlines()
        start = 1
        end   = len(lines) - 1 if lines[-1].strip() == "```" else len(lines)
        return "\n".join(lines[start:end]).strip()
    return stripped