PROTHAM commited on
Commit
4423d13
·
1 Parent(s): c70d39c

Fix final phase 1 and phase 2 inference and scoring issues

Browse files
__pycache__/__init__.cpython-312.pyc ADDED
Binary file (334 Bytes). View file
 
__pycache__/client.cpython-312.pyc CHANGED
Binary files a/__pycache__/client.cpython-312.pyc and b/__pycache__/client.cpython-312.pyc differ
 
__pycache__/models.cpython-312.pyc CHANGED
Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ
 
client.py CHANGED
@@ -1,4 +1,10 @@
1
- from openenv.core.client import EnvClient
 
 
 
 
 
 
2
  from models import APIAction, APIObservation, APIState
3
 
4
 
 
1
+ try:
2
+ from openenv.core.client import EnvClient
3
+ except ImportError:
4
+ try:
5
+ from openenv.core.env_client import EnvClient
6
+ except ImportError:
7
+ from openenv.core import EnvClient
8
  from models import APIAction, APIObservation, APIState
9
 
10
 
graders/__pycache__/grader.cpython-312.pyc CHANGED
Binary files a/graders/__pycache__/grader.cpython-312.pyc and b/graders/__pycache__/grader.cpython-312.pyc differ
 
graders/grader.py CHANGED
@@ -34,17 +34,8 @@ class APIGrader:
34
  def _schema_match(self, body, schema):
35
  if not schema:
36
  return 1.0
37
-
38
- schemas = schema if isinstance(schema, list) else [schema]
39
- best_match = 0.0
40
-
41
- for s in schemas:
42
- matched = sum(1 for k, v in s.items() if k in body and type(body[k]) == type(v))
43
- score = matched / len(s) if len(s) > 0 else 1.0
44
- if score > best_match:
45
- best_match = score
46
-
47
- return best_match
48
 
49
  def get_feedback(self, status, expected_status):
50
  if status == expected_status:
 
34
  def _schema_match(self, body, schema):
35
  if not schema:
36
  return 1.0
37
+ matched = sum(1 for k, v in schema.items() if k in body and type(body[k]) == type(v))
38
+ return matched / len(schema) if len(schema) > 0 else 1.0
 
 
 
 
 
 
 
 
 
39
 
40
  def get_feedback(self, status, expected_status):
41
  if status == expected_status:
inference.py CHANGED
@@ -47,7 +47,7 @@ Respond ONLY in valid JSON with exactly these fields:
47
  }
48
  Rules:
49
  - method must be uppercase (GET, POST, DELETE, etc.)
50
- - url must start with /mock_api/
51
  - body must be null for GET/DELETE requests
52
  - always include Content-Type: application/json when body is not null
53
  - do not add any text outside the JSON
@@ -64,7 +64,7 @@ def log_step(step: int, action: str, reward: float, done: bool,
64
  error_val = error if error else "null"
65
  done_val = str(done).lower()
66
  print(
67
- f"[STEP] step={step} action={action} reward={reward:.2f}"
68
  f" done={done_val} error={error_val}",
69
  flush=True,
70
  )
@@ -72,7 +72,7 @@ def log_step(step: int, action: str, reward: float, done: bool,
72
 
73
  def log_end(success: bool, steps: int, score: float,
74
  rewards: List[float]) -> None:
75
- rewards_str = ",".join(f"{r:.2f}" for r in rewards)
76
  print(
77
  f"[END] success={str(success).lower()} steps={steps}"
78
  f" score={score:.3f} rewards={rewards_str}",
@@ -222,12 +222,12 @@ async def run_task(client: httpx.AsyncClient, task_id: str) -> float:
222
  # ── Step ──────────────────────────────────────────────────────────
223
  try:
224
  result = await env_step(client, action)
225
- reward = float(result.get("reward", 0.0))
226
  done = bool(result.get("done", False))
227
  obs = result.get("observation", result)
228
  error_str: Optional[str] = None
229
  except RuntimeError as exc:
230
- reward = 0.0
231
  done = True # abort episode on env error
232
  error_str = str(exc)[:120]
233
 
@@ -240,7 +240,7 @@ async def run_task(client: httpx.AsyncClient, task_id: str) -> float:
240
  history.append(
241
  f"step={step} action={action_str} "
242
  f"status={obs.get('last_status_code', '?')} "
243
- f"reward={reward:.2f}"
244
  )
245
 
246
  if done:
 
47
  }
48
  Rules:
49
  - method must be uppercase (GET, POST, DELETE, etc.)
50
+ - url must start with / (e.g. /mock_api/... for standard endpoints, or /openapi.json for discovery tasks)
51
  - body must be null for GET/DELETE requests
52
  - always include Content-Type: application/json when body is not null
53
  - do not add any text outside the JSON
 
64
  error_val = error if error else "null"
65
  done_val = str(done).lower()
66
  print(
67
+ f"[STEP] step={step} action={action} reward={reward:.3f}"
68
  f" done={done_val} error={error_val}",
69
  flush=True,
70
  )
 
72
 
73
  def log_end(success: bool, steps: int, score: float,
74
  rewards: List[float]) -> None:
75
+ rewards_str = ",".join(f"{r:.3f}" for r in rewards)
76
  print(
77
  f"[END] success={str(success).lower()} steps={steps}"
78
  f" score={score:.3f} rewards={rewards_str}",
 
222
  # ── Step ──────────────────────────────────────────────────────────
223
  try:
224
  result = await env_step(client, action)
225
+ reward = float(result.get("reward", 0.001))
226
  done = bool(result.get("done", False))
227
  obs = result.get("observation", result)
228
  error_str: Optional[str] = None
229
  except RuntimeError as exc:
230
+ reward = 0.001
231
  done = True # abort episode on env error
232
  error_str = str(exc)[:120]
233
 
 
240
  history.append(
241
  f"step={step} action={action_str} "
242
  f"status={obs.get('last_status_code', '?')} "
243
+ f"reward={reward:.3f}"
244
  )
245
 
246
  if done:
issues_and_fixes.txt ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ API DEBUG ENV — REMAINING ISSUES & FIXES
2
+ ==========================================
3
+
4
+ 1. server/Dockerfile port still 8000
5
+ Fix: Change EXPOSE 8000 → EXPOSE 7860, healthcheck URL to localhost:7860, and CMD port to 7860.
6
+
7
+ 2. MOCK_BASE hardcoded in api_debug_environment.py
8
+ Fix: Replace `MOCK_BASE = "http://localhost:7860"` with `MOCK_BASE = os.getenv("MOCK_BASE_URL", "http://localhost:7860")` and add `import os` at top.
9
+
10
+ 3. resp_body not capped in api_debug_environment.py
11
+ Fix: Add `MAX_RESPONSE_BODY_LENGTH = 2000` at top and change `resp_body = resp.text` to `resp_body = resp.text[:MAX_RESPONSE_BODY_LENGTH]`.
12
+
13
+ 4. APIState.visited_endpoints type will break Pydantic v2
14
+ Fix: In models.py add `from typing import Set` and change `visited_endpoints: set` to `visited_endpoints: Set[str]`.
15
+
16
+ 5. expert_stateful_chain body has literal placeholder key "{dynamic_item_field}"
17
+ Fix: In tasks/expert.py change the broken_request body from `{"{dynamic_item_field}": "temporary_item"}` to `{"name": "temporary_item"}`. The dynamic field feature doesn't format body keys, only description strings.
18
+
19
+ 6. SYSTEM_PROMPT in inference.py blocks GET /openapi.json for expert discovery task
20
+ Fix: Change the rule `url must start with /mock_api/` to `url must start with / (e.g. /mock_api/... or /openapi.json for discovery tasks)`.
21
+
22
+ 7. No tests in submission
23
+ Fix: Add a tests/ directory with at minimum test_environment.py covering grader unit tests, task registry validation, and a mocked episode lifecycle. Run with pytest tests/ -v.
24
+
25
+ 8. _schema_match list-handling code in grader.py is dead/unreachable
26
+ Fix: Remove the `schemas = schema if isinstance(schema, list) else [schema]` block — the environment already passes a single dict (target_schema) at the call site. The dead code adds confusion without benefit.
27
+
28
+ 9. inference.py SYSTEM_PROMPT temperature note — minor
29
+ Fix: Already set to 0.0 (deterministic), good. No change needed.
30
+
31
+ 10. hard_token_exchange description uses {client_id}/{client_secret} placeholders
32
+ These are formatted correctly in reset() so this works. No fix needed — just confirming it's intentional.
models.py CHANGED
@@ -8,6 +8,7 @@ except ImportError:
8
  except ImportError:
9
  from openenv.core.env_server import Action, Observation, State
10
 
 
11
  from pydantic import Field
12
 
13
 
@@ -39,5 +40,5 @@ class APIState(State):
39
  max_steps: int = Field(default=10)
40
  solved: bool = Field(default=False)
41
  current_step_index: int = Field(default=0)
42
- visited_endpoints: set = Field(default_factory=set)
43
  curriculum_level: int = Field(default=0)
 
8
  except ImportError:
9
  from openenv.core.env_server import Action, Observation, State
10
 
11
+ from typing import Set
12
  from pydantic import Field
13
 
14
 
 
40
  max_steps: int = Field(default=10)
41
  solved: bool = Field(default=False)
42
  current_step_index: int = Field(default=0)
43
+ visited_endpoints: Set[str] = Field(default_factory=set)
44
  curriculum_level: int = Field(default=0)
server/Dockerfile CHANGED
@@ -14,9 +14,9 @@ RUN pip install --no-cache-dir -e .
14
  ENV PYTHONPATH=/app
15
 
16
  USER envuser
17
- EXPOSE 8000
18
 
19
  HEALTHCHECK --interval=20s --timeout=8s --start-period=40s --retries=3 \
20
- CMD curl -f http://localhost:8000/health || exit 1
21
 
22
- CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
 
14
  ENV PYTHONPATH=/app
15
 
16
  USER envuser
17
+ EXPOSE 7860
18
 
19
  HEALTHCHECK --interval=20s --timeout=8s --start-period=40s --retries=3 \
20
+ CMD curl -f http://localhost:7860/health || exit 1
21
 
22
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
server/__pycache__/api_debug_environment.cpython-312.pyc CHANGED
Binary files a/server/__pycache__/api_debug_environment.cpython-312.pyc and b/server/__pycache__/api_debug_environment.cpython-312.pyc differ
 
server/api_debug_environment.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import random
2
  import httpx
3
 
@@ -11,7 +12,8 @@ from tasks.registry import TASK_REGISTRY
11
  from graders.grader import APIGrader
12
 
13
  GRADER = APIGrader()
14
- MOCK_BASE = "http://localhost:7860"
 
15
 
16
 
17
  class APIDebugEnvironment(Environment):
@@ -104,7 +106,7 @@ class APIDebugEnvironment(Environment):
104
  )
105
  status = resp.status_code
106
  resp_headers = dict(resp.headers)
107
- resp_body = resp.text
108
  except Exception as e:
109
  status = 0
110
  resp_headers = {}
 
1
+ import os
2
  import random
3
  import httpx
4
 
 
12
  from graders.grader import APIGrader
13
 
14
  GRADER = APIGrader()
15
+ MOCK_BASE = os.getenv("MOCK_BASE_URL", "http://localhost:7860")
16
+ MAX_RESPONSE_BODY_LENGTH = 2000
17
 
18
 
19
  class APIDebugEnvironment(Environment):
 
106
  )
107
  status = resp.status_code
108
  resp_headers = dict(resp.headers)
109
+ resp_body = resp.text[:MAX_RESPONSE_BODY_LENGTH]
110
  except Exception as e:
111
  status = 0
112
  resp_headers = {}
tasks/__pycache__/easy.cpython-312.pyc CHANGED
Binary files a/tasks/__pycache__/easy.cpython-312.pyc and b/tasks/__pycache__/easy.cpython-312.pyc differ
 
tasks/__pycache__/expert.cpython-312.pyc CHANGED
Binary files a/tasks/__pycache__/expert.cpython-312.pyc and b/tasks/__pycache__/expert.cpython-312.pyc differ
 
tasks/__pycache__/medium.cpython-312.pyc CHANGED
Binary files a/tasks/__pycache__/medium.cpython-312.pyc and b/tasks/__pycache__/medium.cpython-312.pyc differ
 
tasks/easy.py CHANGED
@@ -11,6 +11,7 @@ EASY_TASKS = [
11
  },
12
  "expected_status": 200,
13
  "expected_schema": {"users": []},
 
14
  },
15
  {
16
  "id": "easy_content_type",
@@ -24,6 +25,7 @@ EASY_TASKS = [
24
  },
25
  "expected_status": 200,
26
  "expected_schema": {"item_id": "", "name": "", "created": True},
 
27
  },
28
  {
29
  "id": "easy_query_param",
@@ -37,5 +39,6 @@ EASY_TASKS = [
37
  },
38
  "expected_status": 200,
39
  "expected_schema": {"results": [], "total": 0},
 
40
  },
41
  ]
 
11
  },
12
  "expected_status": 200,
13
  "expected_schema": {"users": []},
14
+ "max_steps": 10,
15
  },
16
  {
17
  "id": "easy_content_type",
 
25
  },
26
  "expected_status": 200,
27
  "expected_schema": {"item_id": "", "name": "", "created": True},
28
+ "max_steps": 10,
29
  },
30
  {
31
  "id": "easy_query_param",
 
39
  },
40
  "expected_status": 200,
41
  "expected_schema": {"results": [], "total": 0},
42
+ "max_steps": 10,
43
  },
44
  ]
tasks/expert.py CHANGED
@@ -15,17 +15,17 @@ EXPERT_TASKS = [
15
  },
16
  {
17
  "id": "expert_stateful_chain",
18
- "description": "First, create an item with field '{dynamic_item_field}' set to 'temporary_item' via POST /mock_api/items. Then, safely clean it up by sending a DELETE request to /mock_api/items/{{item_id}}. The current request is a placeholder.",
19
  "broken_request": {
20
  "method": "POST",
21
  "url": "/mock_api/items",
22
  "headers": {"Content-Type": "application/json"},
23
- "body": {"{dynamic_item_field}": "temporary_item"},
24
  "query_params": {},
25
  },
26
  "expected_status": [200, 200],
27
  "expected_schema": [
28
- {"item_id": "", "{dynamic_item_field}": "", "created": True},
29
  {"status": "", "item_id": ""}
30
  ],
31
  "max_steps": 10,
 
15
  },
16
  {
17
  "id": "expert_stateful_chain",
18
+ "description": "First, create an item with field 'name' set to 'temporary_item' via POST /mock_api/items. Then, safely clean it up by sending a DELETE request to /mock_api/items/{item_id}. The current request is a placeholder.",
19
  "broken_request": {
20
  "method": "POST",
21
  "url": "/mock_api/items",
22
  "headers": {"Content-Type": "application/json"},
23
+ "body": {"name": "temporary_item"},
24
  "query_params": {},
25
  },
26
  "expected_status": [200, 200],
27
  "expected_schema": [
28
+ {"item_id": "", "name": "", "created": True},
29
  {"status": "", "item_id": ""}
30
  ],
31
  "max_steps": 10,
tasks/medium.py CHANGED
@@ -11,6 +11,7 @@ MEDIUM_TASKS = [
11
  },
12
  "expected_status": 200,
13
  "expected_schema": {"order_id": 0, "status": ""},
 
14
  },
15
  {
16
  "id": "medium_type_mismatch",
@@ -24,6 +25,7 @@ MEDIUM_TASKS = [
24
  },
25
  "expected_status": 200,
26
  "expected_schema": {"order_id": 0, "product_id": 0},
 
27
  },
28
  {
29
  "id": "medium_nested_field",
@@ -37,5 +39,6 @@ MEDIUM_TASKS = [
37
  },
38
  "expected_status": 200,
39
  "expected_schema": {"name": "", "address": {}, "updated": True},
 
40
  },
41
  ]
 
11
  },
12
  "expected_status": 200,
13
  "expected_schema": {"order_id": 0, "status": ""},
14
+ "max_steps": 10,
15
  },
16
  {
17
  "id": "medium_type_mismatch",
 
25
  },
26
  "expected_status": 200,
27
  "expected_schema": {"order_id": 0, "product_id": 0},
28
+ "max_steps": 10,
29
  },
30
  {
31
  "id": "medium_nested_field",
 
39
  },
40
  "expected_status": 200,
41
  "expected_schema": {"name": "", "address": {}, "updated": True},
42
+ "max_steps": 10,
43
  },
44
  ]
tests/__init__.py ADDED
File without changes
tests/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (163 Bytes). View file
 
tests/__pycache__/test_environment.cpython-312-pytest-8.4.2.pyc ADDED
Binary file (32.4 kB). View file
 
tests/test_environment.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for API Debug Environment
3
+ ================================
4
+ Covers:
5
+ 1. Grader unit tests (grade + _schema_match + get_feedback)
6
+ 2. Task registry validation (all tasks have required keys)
7
+ 3. Mocked episode lifecycle (reset → step → done)
8
+
9
+ Run with:
10
+ pytest tests/ -v
11
+ """
12
+
13
+ import json
14
+ import sys
15
+ import os
16
+ from unittest.mock import MagicMock, patch
17
+
18
+ # Ensure project root is on sys.path so imports resolve without installation
19
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
20
+
21
+ import pytest
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # 1. Grader unit tests
26
+ # ---------------------------------------------------------------------------
27
+
28
+ class TestAPIGrader:
29
+ """Unit tests for APIGrader.grade(), ._schema_match(), .get_feedback()."""
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def grader(self):
33
+ from graders.grader import APIGrader
34
+ self.g = APIGrader()
35
+
36
+ # --- _schema_match ---
37
+
38
+ def test_schema_match_empty_schema_returns_1(self):
39
+ assert self.g._schema_match({"foo": "bar"}, {}) == 1.0
40
+
41
+ def test_schema_match_full_match(self):
42
+ body = {"id": 1, "name": "test", "active": True}
43
+ schema = {"id": 0, "name": ""}
44
+ assert self.g._schema_match(body, schema) == 1.0
45
+
46
+ def test_schema_match_partial_match(self):
47
+ body = {"id": 1, "name": "test"}
48
+ schema = {"id": 0, "name": "", "missing_key": "x"}
49
+ score = self.g._schema_match(body, schema)
50
+ # 2 out of 3 keys match
51
+ assert abs(score - 2/3) < 1e-9
52
+
53
+ def test_schema_match_type_mismatch(self):
54
+ body = {"id": "string_not_int"}
55
+ schema = {"id": 0}
56
+ assert self.g._schema_match(body, schema) == 0.0
57
+
58
+ # --- grade ---
59
+
60
+ def test_grade_wrong_status_returns_partial(self):
61
+ score = self.g.grade(
62
+ response_status=404, response_body="not found",
63
+ expected_status=200, expected_schema={"id": 0},
64
+ attempt=1, max_steps=5,
65
+ )
66
+ assert 0.001 <= score <= 0.999
67
+ assert score < 0.5 # should be a small partial score
68
+
69
+ def test_grade_correct_status_non_json_body(self):
70
+ score = self.g.grade(
71
+ response_status=200, response_body="not json!",
72
+ expected_status=200, expected_schema={"id": 0},
73
+ attempt=1, max_steps=5,
74
+ )
75
+ assert score == 0.25
76
+
77
+ def test_grade_correct_status_perfect_schema(self):
78
+ body_dict = {"id": 1, "name": "alice"}
79
+ score = self.g.grade(
80
+ response_status=200,
81
+ response_body=json.dumps(body_dict),
82
+ expected_status=200,
83
+ expected_schema={"id": 0, "name": ""},
84
+ attempt=1, max_steps=5,
85
+ )
86
+ assert score >= 0.8
87
+
88
+ def test_grade_score_clamped(self):
89
+ # Even with a perfect response the score must be < 1.0
90
+ body_dict = {"status": "ok"}
91
+ score = self.g.grade(
92
+ response_status=200,
93
+ response_body=json.dumps(body_dict),
94
+ expected_status=200,
95
+ expected_schema={"status": ""},
96
+ attempt=1, max_steps=5,
97
+ )
98
+ assert 0.001 <= score <= 0.999
99
+
100
+ # --- get_feedback ---
101
+
102
+ def test_feedback_correct_status(self):
103
+ msg = self.g.get_feedback(200, 200)
104
+ assert "Correct" in msg
105
+
106
+ def test_feedback_401(self):
107
+ msg = self.g.get_feedback(401, 200)
108
+ assert "auth" in msg.lower() or "401" in msg
109
+
110
+ def test_feedback_unknown_status(self):
111
+ msg = self.g.get_feedback(418, 200)
112
+ assert "418" in msg
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # 2. Task registry validation
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class TestTaskRegistry:
120
+ """Validates that every task in the registry has the required keys."""
121
+
122
+ REQUIRED_KEYS = {
123
+ "id", "description", "broken_request",
124
+ "expected_status", "expected_schema", "max_steps",
125
+ }
126
+
127
+ REQUIRED_REQUEST_KEYS = {"method", "url", "headers", "body", "query_params"}
128
+
129
+ @pytest.fixture(autouse=True)
130
+ def registry(self):
131
+ from tasks.registry import TASK_REGISTRY
132
+ self.registry = TASK_REGISTRY
133
+
134
+ def test_all_difficulty_levels_present(self):
135
+ for level in ("easy", "medium", "hard", "expert"):
136
+ assert level in self.registry, f"Missing difficulty level: {level}"
137
+
138
+ def test_each_difficulty_has_at_least_one_task(self):
139
+ for level, tasks in self.registry.items():
140
+ assert len(tasks) >= 1, f"No tasks defined for level: {level}"
141
+
142
+ def test_all_tasks_have_required_keys(self):
143
+ for level, tasks in self.registry.items():
144
+ for task in tasks:
145
+ missing = self.REQUIRED_KEYS - set(task.keys())
146
+ assert not missing, (
147
+ f"Task '{task.get('id', '?')}' in '{level}' is missing keys: {missing}"
148
+ )
149
+
150
+ def test_broken_request_has_required_keys(self):
151
+ for level, tasks in self.registry.items():
152
+ for task in tasks:
153
+ req = task["broken_request"]
154
+ missing = self.REQUIRED_REQUEST_KEYS - set(req.keys())
155
+ assert not missing, (
156
+ f"Task '{task.get('id', '?')}' broken_request missing: {missing}"
157
+ )
158
+
159
+ def test_no_format_placeholder_keys_in_body(self):
160
+ """Ensure no task body contains un-formatted {placeholder} as dict keys."""
161
+ for level, tasks in self.registry.items():
162
+ for task in tasks:
163
+ body = task["broken_request"].get("body") or {}
164
+ for key in body:
165
+ assert not (key.startswith("{") and key.endswith("}")), (
166
+ f"Task '{task.get('id', '?')}' has placeholder key in body: {key!r}"
167
+ )
168
+
169
+ def test_max_steps_positive_int(self):
170
+ for level, tasks in self.registry.items():
171
+ for task in tasks:
172
+ assert isinstance(task["max_steps"], int) and task["max_steps"] > 0
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # 3. Mocked episode lifecycle
177
+ # ---------------------------------------------------------------------------
178
+
179
+ class TestEpisodeLifecycle:
180
+ """Tests the reset → step → done lifecycle using a mocked httpx client."""
181
+
182
+ def _make_env(self, mock_httpx_client):
183
+ """Build an APIDebugEnvironment with httpx patched out."""
184
+ # Patch httpx.Client used in api_debug_environment
185
+ with patch("httpx.Client") as MockClient:
186
+ cm = MockClient.return_value.__enter__.return_value
187
+ cm.post.return_value = MagicMock(status_code=200, text="{}", headers={})
188
+ cm.request.return_value = MagicMock(
189
+ status_code=200,
190
+ text='{"id": 1, "name": "alice"}',
191
+ headers={"content-type": "application/json"},
192
+ )
193
+ import importlib
194
+ import server.api_debug_environment as ade_module
195
+ importlib.reload(ade_module)
196
+ env = ade_module.APIDebugEnvironment()
197
+ return env
198
+
199
+ def test_reset_returns_observation(self):
200
+ with patch("httpx.Client") as MockClient:
201
+ cm = MockClient.return_value.__enter__.return_value
202
+ cm.post.return_value = MagicMock(status_code=200, text="{}", headers={})
203
+ from server.api_debug_environment import APIDebugEnvironment
204
+ env = APIDebugEnvironment()
205
+ obs = env.reset(task_id="easy")
206
+ assert obs is not None
207
+ assert hasattr(obs, "task_description")
208
+ assert not obs.done
209
+
210
+ def test_step_increments_step_count(self):
211
+ with patch("httpx.Client") as MockClient:
212
+ cm = MockClient.return_value.__enter__.return_value
213
+ cm.post.return_value = MagicMock(status_code=200, text="{}", headers={})
214
+ cm.request.return_value = MagicMock(
215
+ status_code=200,
216
+ text='{"id": 1, "name": "alice"}',
217
+ headers={"content-type": "application/json"},
218
+ )
219
+ from server.api_debug_environment import APIDebugEnvironment
220
+ from models import APIAction
221
+ env = APIDebugEnvironment()
222
+ env.reset(task_id="easy")
223
+ action = APIAction(method="GET", url="/mock_api/users",
224
+ headers={}, body={}, query_params={})
225
+ obs = env.step(action)
226
+ assert env.state.step_count == 1
227
+ assert obs.reward >= 0.001
228
+
229
+ def test_done_when_max_steps_exceeded(self):
230
+ with patch("httpx.Client") as MockClient:
231
+ cm = MockClient.return_value.__enter__.return_value
232
+ cm.post.return_value = MagicMock(status_code=200, text="{}", headers={})
233
+ cm.request.return_value = MagicMock(
234
+ status_code=404,
235
+ text='{"detail":"not found"}',
236
+ headers={},
237
+ )
238
+ from server.api_debug_environment import APIDebugEnvironment
239
+ from models import APIAction
240
+ env = APIDebugEnvironment()
241
+ env.reset(task_id="easy")
242
+ action = APIAction(method="GET", url="/mock_api/wrong",
243
+ headers={}, body={}, query_params={})
244
+ max_steps = env.state.max_steps
245
+ obs = None
246
+ for _ in range(max_steps):
247
+ obs = env.step(action)
248
+ assert obs.done