alpiecode 9.0.2__tar.gz → 9.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alpiecode-9.0.2 → alpiecode-9.0.4}/PKG-INFO +1 -1
- {alpiecode-9.0.2 → alpiecode-9.0.4}/pyproject.toml +1 -1
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/SOURCES.txt +1 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/__init__.py +1 -1
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/openai_backend.py +1 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/config.py +16 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/discovery.py +84 -2
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/executor.py +57 -5
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/local_model.py +15 -2
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/orchestrator.py +64 -17
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/progress.py +45 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/prompt.py +9 -3
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/rephraser.py +11 -2
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/tools.py +90 -26
- alpiecode-9.0.4/test/test_claude_codex_capabilities.py +111 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/README.md +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/setup.cfg +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode/__init__.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/agent.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/base.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/cache.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/cli.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/client.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/compaction.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/context.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/doctor.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/git_ops.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/github.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/guardian.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/guardrails.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/media.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/memory.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/server.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/session.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/updater.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/vscode_installer.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/test/test_phase1.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/test/test_phase2.py +0 -0
- {alpiecode-9.0.2 → alpiecode-9.0.4}/test/test_phase3.py +0 -0
|
@@ -40,6 +40,7 @@ src/codeagent/backends/base.py
|
|
|
40
40
|
src/codeagent/backends/local_backend.py
|
|
41
41
|
src/codeagent/backends/openai_backend.py
|
|
42
42
|
src/codeagent/extension/alpiecode.vsix
|
|
43
|
+
test/test_claude_codex_capabilities.py
|
|
43
44
|
test/test_phase1.py
|
|
44
45
|
test/test_phase2.py
|
|
45
46
|
test/test_phase3.py
|
|
@@ -16,7 +16,7 @@ Supports:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional, Any, Dict
|
|
18
18
|
|
|
19
|
-
__version__ = "9.0.
|
|
19
|
+
__version__ = "9.0.4"
|
|
20
20
|
|
|
21
21
|
def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
|
|
22
22
|
"""Run an autonomous coding task programmatically."""
|
|
@@ -133,6 +133,16 @@ def load_config() -> Config:
|
|
|
133
133
|
data["api_key"] = os.environ[f"{prefix}API_KEY"]
|
|
134
134
|
if os.environ.get("HF_TOKEN"):
|
|
135
135
|
data["hf_token"] = os.environ["HF_TOKEN"]
|
|
136
|
+
|
|
137
|
+
# Context size overrides from environment
|
|
138
|
+
for ctx_var in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE", "ALPIECODE_CONTEXT_SIZE", "CODEAGENT_N_CTX"):
|
|
139
|
+
if os.environ.get(ctx_var):
|
|
140
|
+
try:
|
|
141
|
+
data["n_ctx"] = int(os.environ[ctx_var])
|
|
142
|
+
break
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
136
146
|
if os.environ.get("ALPIECODE_CPU") == "1":
|
|
137
147
|
data["n_gpu_layers"] = 0
|
|
138
148
|
elif os.environ.get("ALPIECODE_GPU_LAYERS"):
|
|
@@ -141,6 +151,12 @@ def load_config() -> Config:
|
|
|
141
151
|
except ValueError:
|
|
142
152
|
pass
|
|
143
153
|
|
|
154
|
+
# For offline mode, optimize default context size to 4096 to prevent VRAM/RAM bottleneck
|
|
155
|
+
# unless explicitly configured by the user
|
|
156
|
+
if data.get("base_url") == "offline" and not any(os.environ.get(v) for v in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE")):
|
|
157
|
+
if data.get("n_ctx") == 32768:
|
|
158
|
+
data["n_ctx"] = 4096
|
|
159
|
+
|
|
144
160
|
# Filter data to only keys present in Config fields
|
|
145
161
|
valid_keys = {f.name for f in fields(Config)}
|
|
146
162
|
filtered_data = {k: v for k, v in data.items() if k in valid_keys}
|
|
@@ -19,7 +19,7 @@ import subprocess
|
|
|
19
19
|
import sys
|
|
20
20
|
from dataclasses import dataclass, field
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import List, Optional, Tuple
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# -- Task Context ----------------------------------------------------------
|
|
@@ -47,6 +47,7 @@ class TaskContext:
|
|
|
47
47
|
has_tests: bool = False
|
|
48
48
|
entry_points: List[str] = field(default_factory=list)
|
|
49
49
|
dependencies: List[str] = field(default_factory=list)
|
|
50
|
+
symbol_index: Dict[str, List[str]] = field(default_factory=dict)
|
|
50
51
|
|
|
51
52
|
# Budget (computed from complexity)
|
|
52
53
|
max_tokens: int = 8192
|
|
@@ -442,11 +443,26 @@ def compute_complexity(intent: str, repo_info: dict, task: str) -> str:
|
|
|
442
443
|
if intent == "qa":
|
|
443
444
|
return "qa"
|
|
444
445
|
|
|
445
|
-
task_lower = task.lower()
|
|
446
|
+
task_lower = task.lower().strip()
|
|
446
447
|
file_count = repo_info.get("file_count", 0)
|
|
447
448
|
frameworks = repo_info.get("frameworks", [])
|
|
448
449
|
project_type = repo_info.get("project_type", "empty")
|
|
449
450
|
|
|
451
|
+
# Standalone algorithms / simple scripts: keep 'low' complexity so they
|
|
452
|
+
# complete in 1-2 turns without creating multi-file architectures or plans.
|
|
453
|
+
standalone_keywords = [
|
|
454
|
+
"palindrome", "fibonacci", "quicksort", "bubblesort",
|
|
455
|
+
"binary search", "factorial", "hello world", "calculator",
|
|
456
|
+
"is_palindrome", "check palindrome", "reverse string",
|
|
457
|
+
"prime number", "anagram", "todo script", "simple script",
|
|
458
|
+
]
|
|
459
|
+
project_terms = [
|
|
460
|
+
"this project", "this repo", "our repo", "our project", "existing",
|
|
461
|
+
"endpoint", "route", "database", "migration", "service", "component",
|
|
462
|
+
]
|
|
463
|
+
if any(kw in task_lower for kw in standalone_keywords) and not any(pt in task_lower for pt in project_terms):
|
|
464
|
+
return "low"
|
|
465
|
+
|
|
450
466
|
# -- HIGH: explicitly complex multi-component tasks --
|
|
451
467
|
high_keywords = [
|
|
452
468
|
"full stack", "fullstack", "full-stack",
|
|
@@ -584,6 +600,22 @@ def gather_relevant_context(
|
|
|
584
600
|
for ep in task_context.entry_points[:3]:
|
|
585
601
|
_add_file(ep, f"{ep} (entry point)")
|
|
586
602
|
|
|
603
|
+
|
|
604
|
+
# -- Priority 2.5: Matching Codebase Symbols (quick navigation) --
|
|
605
|
+
if getattr(task_context, "symbol_index", None):
|
|
606
|
+
matched_syms = []
|
|
607
|
+
task_terms = [t for t in task_lower.split() if len(t) > 3]
|
|
608
|
+
for fpath, syms in task_context.symbol_index.items():
|
|
609
|
+
rel_lower = fpath.lower()
|
|
610
|
+
syms_str = " ".join(syms).lower()
|
|
611
|
+
if any(term in rel_lower or term in syms_str for term in task_terms) or len(matched_syms) < 3:
|
|
612
|
+
matched_syms.append(f"- `{fpath}`: {', '.join(syms[:4])}")
|
|
613
|
+
if len(matched_syms) >= 6:
|
|
614
|
+
break
|
|
615
|
+
if matched_syms:
|
|
616
|
+
sym_section = "### Codebase Symbol Map (Quick Navigation)\n" + "\n".join(matched_syms)
|
|
617
|
+
sections.append(sym_section)
|
|
618
|
+
|
|
587
619
|
# -- Priority 3: Files mentioned in the task (fuzzy match) --
|
|
588
620
|
task_lower = task.lower()
|
|
589
621
|
try:
|
|
@@ -614,6 +646,48 @@ def gather_relevant_context(
|
|
|
614
646
|
|
|
615
647
|
# -- Main Entry Point -------------------------------------------------------
|
|
616
648
|
|
|
649
|
+
|
|
650
|
+
def build_symbol_index(workdir: Path, max_files: int = 40) -> Dict[str, List[str]]:
|
|
651
|
+
"""Fast AST-based code symbol indexer (<50ms).
|
|
652
|
+
|
|
653
|
+
Extracts classes, methods, and functions across Python files.
|
|
654
|
+
"""
|
|
655
|
+
symbols: Dict[str, List[str]] = {}
|
|
656
|
+
count = 0
|
|
657
|
+
import ast
|
|
658
|
+
|
|
659
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".egg-info"}
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
for path in workdir.rglob("*.py"):
|
|
663
|
+
if any(part in skip_dirs or part.endswith(".egg-info") for part in path.parts):
|
|
664
|
+
continue
|
|
665
|
+
try:
|
|
666
|
+
rel_str = str(path.relative_to(workdir))
|
|
667
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
668
|
+
file_syms = []
|
|
669
|
+
for node in tree.body:
|
|
670
|
+
if isinstance(node, ast.FunctionDef):
|
|
671
|
+
args = [a.arg for a in node.args.args if a.arg != "self"]
|
|
672
|
+
sig = f"def {node.name}({', '.join(args[:3])})"
|
|
673
|
+
file_syms.append(sig)
|
|
674
|
+
elif isinstance(node, ast.ClassDef):
|
|
675
|
+
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef) and not n.name.startswith("__")]
|
|
676
|
+
method_str = f" [{', '.join(methods[:3])}]" if methods else ""
|
|
677
|
+
file_syms.append(f"class {node.name}{method_str}")
|
|
678
|
+
if file_syms:
|
|
679
|
+
symbols[rel_str] = file_syms[:8]
|
|
680
|
+
count += 1
|
|
681
|
+
if count >= max_files:
|
|
682
|
+
break
|
|
683
|
+
except Exception:
|
|
684
|
+
continue
|
|
685
|
+
except Exception:
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
return symbols
|
|
689
|
+
|
|
690
|
+
|
|
617
691
|
def build_task_context(task: str, workdir: Path) -> TaskContext:
|
|
618
692
|
"""Build complete TaskContext by running all discovery phases.
|
|
619
693
|
|
|
@@ -635,7 +709,15 @@ def build_task_context(task: str, workdir: Path) -> TaskContext:
|
|
|
635
709
|
# Phase 5: Budget from complexity
|
|
636
710
|
budget = COMPLEXITY_CONFIG[complexity]
|
|
637
711
|
|
|
712
|
+
symbol_index = {}
|
|
713
|
+
if repo.get("file_count", 0) > 0 and repo.get("project_type") != "empty":
|
|
714
|
+
try:
|
|
715
|
+
symbol_index = build_symbol_index(workdir)
|
|
716
|
+
except Exception:
|
|
717
|
+
pass
|
|
718
|
+
|
|
638
719
|
return TaskContext(
|
|
720
|
+
symbol_index=symbol_index,
|
|
639
721
|
# Intent
|
|
640
722
|
intent=intent,
|
|
641
723
|
complexity=complexity,
|
|
@@ -39,11 +39,15 @@ def parse_text_tool_calls(text: str) -> List[dict]:
|
|
|
39
39
|
|
|
40
40
|
tool_calls = []
|
|
41
41
|
|
|
42
|
-
# 1. Try parsing
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
# 1. Try parsing blocks inside <tool_call> or ```json using robust JSON repair
|
|
43
|
+
from .backends.openai_backend import _repair_and_parse_json_arguments
|
|
44
|
+
blocks = re.findall(r"(?:<tool_call>|```json)\s*(.*?)\s*(?:</tool_call>|```|$)", text, re.DOTALL)
|
|
45
|
+
for block in blocks:
|
|
46
|
+
raw = block.strip()
|
|
47
|
+
if not raw:
|
|
48
|
+
continue
|
|
45
49
|
try:
|
|
46
|
-
data =
|
|
50
|
+
data = _repair_and_parse_json_arguments(raw)
|
|
47
51
|
if isinstance(data, dict) and "name" in data:
|
|
48
52
|
tool_calls.append({
|
|
49
53
|
"name": data["name"],
|
|
@@ -146,6 +150,42 @@ def _parse_error_hint(stderr: str, stdout: str) -> str:
|
|
|
146
150
|
return "\n".join(hints)
|
|
147
151
|
|
|
148
152
|
|
|
153
|
+
|
|
154
|
+
def suggest_similar_file(workdir: Path, target_path: str) -> Optional[str]:
|
|
155
|
+
"""Find the closest existing file path in the workspace for typos/misspellings."""
|
|
156
|
+
import difflib
|
|
157
|
+
target_name = Path(target_path).name.lower()
|
|
158
|
+
candidates = []
|
|
159
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
|
160
|
+
try:
|
|
161
|
+
for p in workdir.rglob("*"):
|
|
162
|
+
if p.is_file() and not any(part in skip_dirs for part in p.parts):
|
|
163
|
+
candidates.append(str(p.relative_to(workdir)))
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if not candidates:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# 1. Exact filename match in another directory
|
|
171
|
+
for c in candidates:
|
|
172
|
+
if Path(c).name.lower() == target_name:
|
|
173
|
+
return c
|
|
174
|
+
|
|
175
|
+
# 2. Fuzzy match on relative path
|
|
176
|
+
matches = difflib.get_close_matches(target_path, candidates, n=1, cutoff=0.55)
|
|
177
|
+
if matches:
|
|
178
|
+
return matches[0]
|
|
179
|
+
|
|
180
|
+
# 3. Fuzzy match on filename
|
|
181
|
+
file_matches = difflib.get_close_matches(target_name, [Path(c).name.lower() for c in candidates], n=1, cutoff=0.55)
|
|
182
|
+
if file_matches:
|
|
183
|
+
for c in candidates:
|
|
184
|
+
if Path(c).name.lower() == file_matches[0]:
|
|
185
|
+
return c
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
149
189
|
class ToolExecutor:
|
|
150
190
|
"""Executes tool calls for a workspace."""
|
|
151
191
|
|
|
@@ -160,7 +200,7 @@ class ToolExecutor:
|
|
|
160
200
|
if response.tool_calls:
|
|
161
201
|
return response.tool_calls
|
|
162
202
|
|
|
163
|
-
if response.content
|
|
203
|
+
if response.content:
|
|
164
204
|
parsed = parse_text_tool_calls(response.content)
|
|
165
205
|
return [
|
|
166
206
|
ToolCall(
|
|
@@ -215,6 +255,12 @@ class ToolExecutor:
|
|
|
215
255
|
res_str = f"error: {e}"
|
|
216
256
|
if on_tool_end:
|
|
217
257
|
on_tool_end(tc.name, res_str)
|
|
258
|
+
if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
|
|
259
|
+
req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
|
|
260
|
+
if req_path:
|
|
261
|
+
sugg = suggest_similar_file(self.workdir, req_path)
|
|
262
|
+
if sugg and sugg != req_path:
|
|
263
|
+
res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
|
|
218
264
|
results_map[tc.id] = ToolResult(
|
|
219
265
|
tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
|
|
220
266
|
)
|
|
@@ -270,6 +316,12 @@ class ToolExecutor:
|
|
|
270
316
|
res_str = f"error: {e}"
|
|
271
317
|
if on_tool_end:
|
|
272
318
|
on_tool_end(tc.name, res_str)
|
|
319
|
+
if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
|
|
320
|
+
req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
|
|
321
|
+
if req_path:
|
|
322
|
+
sugg = suggest_similar_file(self.workdir, req_path)
|
|
323
|
+
if sugg and sugg != req_path:
|
|
324
|
+
res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
|
|
273
325
|
results_map[tc.id] = ToolResult(
|
|
274
326
|
tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
|
|
275
327
|
)
|
|
@@ -68,7 +68,8 @@ def detect_gpu() -> int:
|
|
|
68
68
|
if res.returncode == 0 and res.stdout.strip():
|
|
69
69
|
vram_mb = int(res.stdout.strip().split("\n")[0].strip())
|
|
70
70
|
if vram_mb <= 6500: # 6GB VRAM (e.g. RTX 3050 Laptop)
|
|
71
|
-
|
|
71
|
+
# Full offload: 4.4GB model + 4096 context fits in 6GB VRAM and runs 5x faster than partial offload
|
|
72
|
+
return 33
|
|
72
73
|
return -1 # 8GB+ VRAM — offload all layers
|
|
73
74
|
except Exception:
|
|
74
75
|
pass
|
|
@@ -303,7 +304,19 @@ class LocalModel:
|
|
|
303
304
|
model_path = self.ensure_model()
|
|
304
305
|
Llama = _ensure_llama_cpp()
|
|
305
306
|
|
|
306
|
-
|
|
307
|
+
has_gpu_offload = False
|
|
308
|
+
try:
|
|
309
|
+
import llama_cpp
|
|
310
|
+
has_gpu_offload = getattr(llama_cpp, "llama_supports_gpu_offload", lambda: False)()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
if self.n_gpu_layers != 0 and not has_gpu_offload:
|
|
315
|
+
print(" ⚠️ Warning: Active Python environment has no CUDA llama-cpp support. Running on CPU.")
|
|
316
|
+
accel = "CPU (No CUDA in Python env)"
|
|
317
|
+
self.n_gpu_layers = 0
|
|
318
|
+
else:
|
|
319
|
+
accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
|
|
307
320
|
|
|
308
321
|
# ── Smart P-Core Topology Auto-Tuning ─────────────────────────
|
|
309
322
|
# On hybrid CPUs (Intel 12th+), over-subscribing E-cores causes
|
|
@@ -413,27 +413,74 @@ class AgentOrchestrator:
|
|
|
413
413
|
"commands": session_executed_commands,
|
|
414
414
|
})
|
|
415
415
|
|
|
416
|
-
|
|
417
|
-
# If files were written/modified but no verification was run, nudge the agent
|
|
416
|
+
# ── Component 1: Autonomous Verify-Before-Done Gate ──
|
|
418
417
|
has_code_files = any(
|
|
419
418
|
f.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".c", ".cpp"))
|
|
420
419
|
for f in session_touched_files
|
|
421
420
|
)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if has_code_files and not
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
421
|
+
auto_verify_passed = getattr(session, "_auto_verify_passed", False)
|
|
422
|
+
verify_attempts = getattr(session, "_auto_verify_attempts", 0)
|
|
423
|
+
|
|
424
|
+
if has_code_files and not auto_verify_passed and verify_attempts < 2:
|
|
425
|
+
# 1. In-process Python syntax verification for all modified Python files
|
|
426
|
+
syntax_errs = []
|
|
427
|
+
for tf in session_touched_files:
|
|
428
|
+
if tf.endswith(".py"):
|
|
429
|
+
full_tf = session.workdir / tf
|
|
430
|
+
if full_tf.exists():
|
|
431
|
+
try:
|
|
432
|
+
import ast as _ast
|
|
433
|
+
_ast.parse(full_tf.read_text(encoding="utf-8", errors="replace"))
|
|
434
|
+
except SyntaxError as se:
|
|
435
|
+
syntax_errs.append(f"- '{tf}' line {se.lineno}: {se.msg}")
|
|
436
|
+
|
|
437
|
+
if syntax_errs:
|
|
438
|
+
session._auto_verify_attempts = verify_attempts + 1
|
|
439
|
+
err_msg = "\n".join(syntax_errs)
|
|
440
|
+
session.context.add_user_message(
|
|
441
|
+
f"[AUTO-VERIFICATION FAILED] Syntax error(s) detected in your code:\n{err_msg}\n"
|
|
442
|
+
"Please use edit_file to fix these syntax errors before completing the task."
|
|
443
|
+
)
|
|
444
|
+
yield AgentEvent("verification_failed", {
|
|
445
|
+
"type": "syntax",
|
|
446
|
+
"errors": syntax_errs,
|
|
447
|
+
})
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
# 2. Automated test verification if project has tests
|
|
451
|
+
if task_context.has_tests and not getattr(session, "_has_run_tests_in_session", False):
|
|
452
|
+
session._auto_verify_attempts = verify_attempts + 1
|
|
453
|
+
session._has_run_tests_in_session = True
|
|
454
|
+
test_cmd = "python3 -m pytest -q --tb=short" if task_context.project_type == "python" else "npm test" if task_context.project_type == "node" else None
|
|
455
|
+
if test_cmd:
|
|
456
|
+
yield AgentEvent("status", {
|
|
457
|
+
"phase": "verifying",
|
|
458
|
+
"message": f"Auto-running verification test suite ({test_cmd})...",
|
|
459
|
+
})
|
|
460
|
+
t_res = session.executor.dispatch.get("bash", lambda a: "")({"command": test_cmd})
|
|
461
|
+
t_exit = 0
|
|
462
|
+
try:
|
|
463
|
+
import json as _json
|
|
464
|
+
t_data = _json.loads(t_res)
|
|
465
|
+
t_exit = t_data.get("exit_code", 0)
|
|
466
|
+
t_out = t_data.get("stdout", "")
|
|
467
|
+
except Exception:
|
|
468
|
+
t_out = str(t_res)
|
|
469
|
+
|
|
470
|
+
if t_exit != 0:
|
|
471
|
+
session.context.add_user_message(
|
|
472
|
+
f"[AUTO-VERIFICATION FAILED] Test suite failed (exit_code={t_exit}) after your changes:\n"
|
|
473
|
+
f"{t_out[:1200]}\n"
|
|
474
|
+
"Please analyze the failure, fix the issue, and verify it passes before finishing."
|
|
475
|
+
)
|
|
476
|
+
yield AgentEvent("verification_failed", {
|
|
477
|
+
"type": "tests",
|
|
478
|
+
"command": test_cmd,
|
|
479
|
+
"exit_code": t_exit,
|
|
480
|
+
})
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
session._auto_verify_passed = True
|
|
437
484
|
|
|
438
485
|
# Cache if single-turn
|
|
439
486
|
if is_cacheable and turn == 0:
|
|
@@ -24,6 +24,50 @@ class TurnSnapshot:
|
|
|
24
24
|
had_progress: bool = False
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Milestone:
|
|
30
|
+
text: str
|
|
31
|
+
done: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MilestoneTracker:
|
|
35
|
+
"""Tracks progress against markdown task lists (- [ ] or numbered steps)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, task_text: str = ""):
|
|
38
|
+
self.milestones: List[Milestone] = []
|
|
39
|
+
if task_text:
|
|
40
|
+
self.init_from_text(task_text)
|
|
41
|
+
|
|
42
|
+
def init_from_text(self, text: str) -> None:
|
|
43
|
+
import re
|
|
44
|
+
lines = text.splitlines()
|
|
45
|
+
for line in lines:
|
|
46
|
+
match = re.search(r"^\s*(?:-\s*\[([ xX])\]|\d+\.)\s+(.+)$", line)
|
|
47
|
+
if match:
|
|
48
|
+
is_done = match.group(1) in ("x", "X") if match.group(1) else False
|
|
49
|
+
item_text = match.group(2).strip()
|
|
50
|
+
if len(item_text) > 3:
|
|
51
|
+
self.milestones.append(Milestone(text=item_text, done=is_done))
|
|
52
|
+
|
|
53
|
+
def mark_milestone(self, text_fragment: str, done: bool = True) -> None:
|
|
54
|
+
for m in self.milestones:
|
|
55
|
+
if text_fragment.lower() in m.text.lower():
|
|
56
|
+
m.done = done
|
|
57
|
+
|
|
58
|
+
def get_pending(self) -> List[str]:
|
|
59
|
+
return [m.text for m in self.milestones if not m.done]
|
|
60
|
+
|
|
61
|
+
def format_checklist(self) -> str:
|
|
62
|
+
if not self.milestones:
|
|
63
|
+
return ""
|
|
64
|
+
lines = ["### Active Task Checklist:"]
|
|
65
|
+
for m in self.milestones:
|
|
66
|
+
box = "[x]" if m.done else "[ ]"
|
|
67
|
+
lines.append(f"- {box} {m.text}")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
27
71
|
class ProgressMonitor:
|
|
28
72
|
"""Tracks agent progress across turns.
|
|
29
73
|
|
|
@@ -39,6 +83,7 @@ class ProgressMonitor:
|
|
|
39
83
|
self.all_files_created: Set[str] = set()
|
|
40
84
|
self.consecutive_stalls: int = 0
|
|
41
85
|
self.stall_interventions: int = 0
|
|
86
|
+
self.milestones = MilestoneTracker()
|
|
42
87
|
|
|
43
88
|
def record_turn(self, turn: int, tool_calls: list, tool_results: list) -> TurnSnapshot:
|
|
44
89
|
"""Record what happened in a turn and determine if progress was made."""
|
|
@@ -267,9 +267,15 @@ Write the complete code on Turn 1. Verify by running it. Fix if needed.
|
|
|
267
267
|
Rules:
|
|
268
268
|
1. Write clean code using ONLY Python standard library modules.
|
|
269
269
|
2. NEVER run pip, uv pip, or any package install commands — they will fail offline.
|
|
270
|
-
3. For testing, use `
|
|
271
|
-
4.
|
|
272
|
-
|
|
270
|
+
3. For testing, use `python3 -m unittest` (stdlib).
|
|
271
|
+
4. To execute a tool, output a <tool_call> block:
|
|
272
|
+
<tool_call>
|
|
273
|
+
{"name": "write_file", "arguments": {"path": "<file>", "content": "<full_code>"}}
|
|
274
|
+
</tool_call>
|
|
275
|
+
<tool_call>
|
|
276
|
+
{"name": "bash", "arguments": {"command": "<command>"}}
|
|
277
|
+
</tool_call>
|
|
278
|
+
5. Always create the files and run them to verify.
|
|
273
279
|
6. When done and verified, output: DONE: <summary>.
|
|
274
280
|
"""
|
|
275
281
|
|
|
@@ -18,7 +18,10 @@ Your job is to transform the user's raw prompt into a clear, solid, and unambigu
|
|
|
18
18
|
Guidelines:
|
|
19
19
|
1. If the prompt is a simple Q&A, greeting, or direct conceptual question (e.g. "what is X?", "explain Y", "hi", "how does Z work"):
|
|
20
20
|
- Keep it clean, direct, and concise. Do NOT add unnecessary software development boilerplate.
|
|
21
|
-
2. If the prompt is a
|
|
21
|
+
2. If the prompt is a standalone algorithm, utility, or single-file script (e.g. "check palindrome", "fibonacci", "calculator", "sort"):
|
|
22
|
+
- Keep the specification strictly focused on a SINGLE runnable executable script (e.g. `palindrome.py`).
|
|
23
|
+
- Do NOT turn it into a multi-file architecture, microservice, or web framework (do NOT add FastAPI, Flask, Pydantic, Docker, or separate schemas/models/utils unless the user explicitly requested a web service).
|
|
24
|
+
3. If the prompt is a coding, debugging, refactoring, testing, or feature creation task:
|
|
22
25
|
- Expand it into a solid, structured engineering prompt with:
|
|
23
26
|
* Objective: Explicit statement of what needs to be created, modified, or fixed
|
|
24
27
|
* Scope & Architecture: Target files, patterns, and module structure
|
|
@@ -103,10 +106,16 @@ class PromptRephraser:
|
|
|
103
106
|
if backend is None or not getattr(backend, "is_available", True):
|
|
104
107
|
return task
|
|
105
108
|
|
|
109
|
+
# In offline/local GGUF mode, skip redundant rephraser LLM pass to ensure instant turn-0 start
|
|
110
|
+
if type(backend).__name__ == "LocalBackend" or getattr(backend, "is_offline", False):
|
|
111
|
+
return task
|
|
112
|
+
|
|
106
113
|
try:
|
|
107
114
|
# Inject project context if available so rephrased spec is project-aware
|
|
108
115
|
project_hint = ""
|
|
109
|
-
|
|
116
|
+
standalone_kws = ["palindrome", "fibonacci", "quicksort", "bubblesort", "factorial", "hello world", "calculator", "reverse string"]
|
|
117
|
+
is_standalone = any(kw in task.lower() for kw in standalone_kws)
|
|
118
|
+
if task_context and not is_standalone:
|
|
110
119
|
_fw = ", ".join(getattr(task_context, "frameworks", []) or []) or "none"
|
|
111
120
|
_pt = getattr(task_context, "project_type", "unknown")
|
|
112
121
|
_fc = getattr(task_context, "file_count", 0)
|
|
@@ -348,16 +348,33 @@ def _build_venv_env(workdir: Path) -> dict:
|
|
|
348
348
|
|
|
349
349
|
|
|
350
350
|
def _smart_truncate(text: str, max_chars: int = 6000) -> str:
|
|
351
|
-
"""Truncate long output keeping
|
|
352
|
-
|
|
353
|
-
Compiler errors appear at the top (file:line: error: ...) while the
|
|
354
|
-
bottom often has summary counts. Keeping both ends gives the model
|
|
355
|
-
far better error visibility than tail-only truncation.
|
|
356
|
-
"""
|
|
351
|
+
"""Truncate long output keeping head/tail, and distilling test failures / compiler traces."""
|
|
357
352
|
if len(text) <= max_chars:
|
|
358
353
|
return text
|
|
359
|
-
|
|
360
|
-
|
|
354
|
+
|
|
355
|
+
# High-signal error/failure extraction for pytest, jest, compiler outputs
|
|
356
|
+
failure_markers = ["FAILED", "FAILURES", "ERROR:", "FAIL:", "AssertionError:", "Traceback (most recent call last):"]
|
|
357
|
+
if any(m in text for m in failure_markers):
|
|
358
|
+
lines_list = text.splitlines()
|
|
359
|
+
error_lines = []
|
|
360
|
+
in_error = False
|
|
361
|
+
for line in lines_list:
|
|
362
|
+
if any(m in line for m in failure_markers):
|
|
363
|
+
in_error = True
|
|
364
|
+
if in_error:
|
|
365
|
+
error_lines.append(line)
|
|
366
|
+
if len(error_lines) >= 70:
|
|
367
|
+
break
|
|
368
|
+
if error_lines:
|
|
369
|
+
header = "\n".join(lines_list[:15])
|
|
370
|
+
error_block = "\n".join(error_lines)
|
|
371
|
+
footer = "\n".join(lines_list[-15:])
|
|
372
|
+
distilled = f"{header}\n\n... [build logs distilled] ...\n\n### CRITICAL FAILURE DETAILS:\n{error_block}\n\n### SUMMARY:\n{footer}"
|
|
373
|
+
if len(distilled) <= max_chars:
|
|
374
|
+
return distilled
|
|
375
|
+
|
|
376
|
+
head_size = int(max_chars * 0.6)
|
|
377
|
+
tail_size = max_chars - head_size
|
|
361
378
|
omitted = len(text) - head_size - tail_size
|
|
362
379
|
return (
|
|
363
380
|
text[:head_size]
|
|
@@ -366,7 +383,6 @@ def _smart_truncate(text: str, max_chars: int = 6000) -> str:
|
|
|
366
383
|
)
|
|
367
384
|
|
|
368
385
|
|
|
369
|
-
|
|
370
386
|
def _extract_script_path(command: str) -> str:
|
|
371
387
|
"""Extract the script file path from a bash command, if any."""
|
|
372
388
|
import re as _re
|
|
@@ -853,34 +869,82 @@ def _clone_repo(workdir: Path, repo_url: str, branch: str = None) -> str:
|
|
|
853
869
|
# ── Dispatch factory ──────────────────────────────────────────────────
|
|
854
870
|
|
|
855
871
|
def make_dispatch(workdir: Path):
|
|
856
|
-
"""Bind tool implementations to a specific working directory."""
|
|
872
|
+
"""Bind tool implementations to a specific working directory with resilient parameter extraction."""
|
|
873
|
+
|
|
874
|
+
def _safe_bash(a: dict) -> str:
|
|
875
|
+
cmd = a.get("command") or a.get("cmd") or a.get("script") or a.get("shell_command") or ""
|
|
876
|
+
if not str(cmd).strip():
|
|
877
|
+
return "error: 'command' parameter is required and cannot be empty"
|
|
878
|
+
return _bash(workdir, str(cmd))
|
|
879
|
+
|
|
880
|
+
def _safe_read_file(a: dict) -> str:
|
|
881
|
+
path = a.get("path") or a.get("file") or a.get("filepath") or a.get("filename") or ""
|
|
882
|
+
if not str(path).strip():
|
|
883
|
+
return "error: 'path' parameter is required for read_file"
|
|
884
|
+
return _read_file(workdir, str(path), a.get("start_line"), a.get("end_line"))
|
|
885
|
+
|
|
886
|
+
def _safe_write_file(a: dict) -> str:
|
|
887
|
+
path = a.get("path") or a.get("file") or a.get("filepath") or a.get("filename") or ""
|
|
888
|
+
if not str(path).strip():
|
|
889
|
+
return "error: 'path' parameter is required for write_file"
|
|
890
|
+
content = None
|
|
891
|
+
for k in ("content", "code", "text", "body", "file_content", "data", "contents"):
|
|
892
|
+
if k in a and a[k] is not None:
|
|
893
|
+
content = str(a[k])
|
|
894
|
+
break
|
|
895
|
+
if content is None:
|
|
896
|
+
return f"error: 'content' parameter is required for write_file to '{path}'. Please provide the file content."
|
|
897
|
+
return _write_file(workdir, str(path), content)
|
|
898
|
+
|
|
899
|
+
def _safe_edit_file(a: dict) -> str:
|
|
900
|
+
path = a.get("path") or a.get("file") or a.get("filepath") or a.get("filename") or ""
|
|
901
|
+
if not str(path).strip():
|
|
902
|
+
return "error: 'path' parameter is required for edit_file"
|
|
903
|
+
old_str = a.get("old_str") if a.get("old_str") is not None else a.get("old_string") if a.get("old_string") is not None else a.get("target")
|
|
904
|
+
if old_str is None:
|
|
905
|
+
return "error: 'old_str' parameter is required for edit_file (the exact string to replace)"
|
|
906
|
+
new_str = a.get("new_str") if a.get("new_str") is not None else a.get("new_string") if a.get("new_string") is not None else a.get("replacement") if a.get("replacement") is not None else a.get("code") if a.get("code") is not None else ""
|
|
907
|
+
return _edit_file(workdir, str(path), str(old_str), str(new_str))
|
|
908
|
+
|
|
909
|
+
def _safe_file_search(a: dict) -> str:
|
|
910
|
+
pattern = a.get("pattern") or a.get("query") or a.get("regex") or ""
|
|
911
|
+
if not str(pattern).strip():
|
|
912
|
+
return "error: 'pattern' parameter is required for file_search"
|
|
913
|
+
return _file_search(workdir, str(pattern), a.get("path", "."),
|
|
914
|
+
a.get("include"), a.get("case_insensitive", False))
|
|
915
|
+
|
|
916
|
+
def _safe_update_plan(a: dict) -> str:
|
|
917
|
+
plan = a.get("plan") or a.get("content") or a.get("steps") or a.get("summary") or ""
|
|
918
|
+
if not str(plan).strip():
|
|
919
|
+
return "error: 'plan' parameter is required for update_plan"
|
|
920
|
+
return _update_plan(workdir, str(plan))
|
|
921
|
+
|
|
857
922
|
return {
|
|
858
|
-
"bash":
|
|
859
|
-
"read_file":
|
|
860
|
-
"write_file":
|
|
861
|
-
"edit_file":
|
|
862
|
-
"list_files": lambda a: _list_files(workdir, a.get("path"
|
|
863
|
-
"file_search":
|
|
864
|
-
|
|
865
|
-
"
|
|
866
|
-
"
|
|
867
|
-
"
|
|
868
|
-
"
|
|
869
|
-
"
|
|
870
|
-
"view_image": lambda a: _view_image(workdir, a["path"]),
|
|
923
|
+
"bash": _safe_bash,
|
|
924
|
+
"read_file": _safe_read_file,
|
|
925
|
+
"write_file": _safe_write_file,
|
|
926
|
+
"edit_file": _safe_edit_file,
|
|
927
|
+
"list_files": lambda a: _list_files(workdir, a.get("path") or a.get("dir") or ".", a.get("max_depth", 4)),
|
|
928
|
+
"file_search": _safe_file_search,
|
|
929
|
+
"apply_patch": lambda a: _apply_patch(workdir, a.get("path") or a.get("file", ""), a.get("patch") or a.get("diff", "")),
|
|
930
|
+
"web_search": lambda a: _web_search(a.get("query") or a.get("q", ""), a.get("num_results", 5)),
|
|
931
|
+
"fetch_url": lambda a: _fetch_url(a.get("url") or a.get("uri", "")),
|
|
932
|
+
"request_user_input": lambda a: _request_user_input(a.get("question") or a.get("prompt", "")),
|
|
933
|
+
"update_plan": _safe_update_plan,
|
|
934
|
+
"view_image": lambda a: _view_image(workdir, a.get("path") or a.get("file", "")),
|
|
871
935
|
"github_issues": lambda a: _github_issues(
|
|
872
|
-
a
|
|
936
|
+
a.get("owner", ""), a.get("repo", ""),
|
|
873
937
|
issue_number=a.get("issue_number"),
|
|
874
938
|
state=a.get("state", "open"),
|
|
875
939
|
max_results=a.get("max_results", 10),
|
|
876
940
|
),
|
|
877
941
|
"github_browse": lambda a: _github_browse(
|
|
878
|
-
a
|
|
942
|
+
a.get("owner", ""), a.get("repo", ""),
|
|
879
943
|
path=a.get("path", ""),
|
|
880
944
|
info_only=a.get("info_only", False),
|
|
881
945
|
),
|
|
882
946
|
"clone_repo": lambda a: _clone_repo(
|
|
883
|
-
workdir, a
|
|
947
|
+
workdir, a.get("repo_url") or a.get("url", ""),
|
|
884
948
|
branch=a.get("branch"),
|
|
885
949
|
),
|
|
886
950
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for Claude Code and Codex caliber capabilities added to AlpieCode:
|
|
3
|
+
1. Fast AST Codebase Map (Symbol indexing)
|
|
4
|
+
2. Self-Healing File Suggestions
|
|
5
|
+
3. Smart Failure Distillation in Output Truncation
|
|
6
|
+
4. Milestone & Todo Checklist Tracking
|
|
7
|
+
5. Autonomous Verify-Before-Done Gate
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from codeagent.discovery import build_symbol_index, TaskContext
|
|
14
|
+
from codeagent.executor import suggest_similar_file
|
|
15
|
+
from codeagent.tools import _smart_truncate
|
|
16
|
+
from codeagent.progress import MilestoneTracker, ProgressMonitor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_ast_symbol_index_extraction(tmp_path: Path):
|
|
20
|
+
"""Verify that build_symbol_index accurately extracts classes, methods, and functions."""
|
|
21
|
+
sample_file = tmp_path / "sample.py"
|
|
22
|
+
sample_file.write_text(
|
|
23
|
+
"class OrderService:\n"
|
|
24
|
+
" def create_order(self, item_id, qty):\n"
|
|
25
|
+
" pass\n"
|
|
26
|
+
" def cancel_order(self, order_id):\n"
|
|
27
|
+
" pass\n\n"
|
|
28
|
+
"def calculate_tax(subtotal, rate=0.05):\n"
|
|
29
|
+
" return subtotal * rate\n",
|
|
30
|
+
encoding="utf-8"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
symbols = build_symbol_index(tmp_path)
|
|
34
|
+
assert "sample.py" in symbols
|
|
35
|
+
syms = symbols["sample.py"]
|
|
36
|
+
assert any("class OrderService" in s for s in syms)
|
|
37
|
+
assert any("def calculate_tax" in s for s in syms)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_suggest_similar_file(tmp_path: Path):
|
|
41
|
+
"""Verify fuzzy file-not-found suggestion matches close typos."""
|
|
42
|
+
src_dir = tmp_path / "src"
|
|
43
|
+
src_dir.mkdir(parents=True)
|
|
44
|
+
real_file = src_dir / "user_repository.py"
|
|
45
|
+
real_file.write_text("# user repo", encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
# Typo: user_repo.py instead of user_repository.py
|
|
48
|
+
suggestion = suggest_similar_file(tmp_path, "src/user_repo.py")
|
|
49
|
+
assert suggestion == "src/user_repository.py"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_smart_truncate_preserves_failures():
|
|
53
|
+
"""Verify _smart_truncate preserves the exact failure traceback when truncating large logs."""
|
|
54
|
+
noisy_prefix = "PASS test_001.py\n" * 300
|
|
55
|
+
critical_failure = (
|
|
56
|
+
"FAILED test_auth.py::test_login - AssertionError: Status code 401 != 200\n"
|
|
57
|
+
"Traceback (most recent call last):\n"
|
|
58
|
+
" File 'test_auth.py', line 45, in test_login\n"
|
|
59
|
+
" assert response.status_code == 200\n"
|
|
60
|
+
)
|
|
61
|
+
noisy_suffix = "PASS test_100.py\n" * 300
|
|
62
|
+
summary = "=== 1 failed, 600 passed in 4.25s ==="
|
|
63
|
+
|
|
64
|
+
full_log = f"{noisy_prefix}\n{critical_failure}\n{noisy_suffix}\n{summary}"
|
|
65
|
+
assert len(full_log) > 6000
|
|
66
|
+
|
|
67
|
+
truncated = _smart_truncate(full_log, max_chars=3000)
|
|
68
|
+
assert len(truncated) <= 3000
|
|
69
|
+
assert "CRITICAL FAILURE DETAILS" in truncated
|
|
70
|
+
assert "AssertionError: Status code 401 != 200" in truncated
|
|
71
|
+
assert "test_auth.py" in truncated
|
|
72
|
+
assert summary in truncated
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_milestone_tracker():
|
|
76
|
+
"""Verify milestone checklist tracking for planned tasks."""
|
|
77
|
+
plan_markdown = (
|
|
78
|
+
"Here is the plan:\n"
|
|
79
|
+
"- [ ] Step 1: Create database schema\n"
|
|
80
|
+
"- [ ] Step 2: Implement REST endpoints\n"
|
|
81
|
+
"- [x] Step 3: Set up virtualenv\n"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
tracker = MilestoneTracker(plan_markdown)
|
|
85
|
+
assert len(tracker.milestones) == 3
|
|
86
|
+
assert tracker.milestones[0].done is False
|
|
87
|
+
assert tracker.milestones[2].done is True
|
|
88
|
+
|
|
89
|
+
# Mark step 1 done
|
|
90
|
+
tracker.mark_milestone("database schema", done=True)
|
|
91
|
+
assert tracker.milestones[0].done is True
|
|
92
|
+
|
|
93
|
+
pending = tracker.get_pending()
|
|
94
|
+
assert len(pending) == 1
|
|
95
|
+
assert "REST endpoints" in pending[0]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_auto_verification_syntax_detection(tmp_path: Path):
|
|
99
|
+
"""Verify that auto-verification catches syntax errors in modified Python files."""
|
|
100
|
+
import ast
|
|
101
|
+
bad_file = tmp_path / "broken.py"
|
|
102
|
+
bad_file.write_text("def broken(\n return 42", encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
syntax_errs = []
|
|
105
|
+
try:
|
|
106
|
+
ast.parse(bad_file.read_text(encoding="utf-8"))
|
|
107
|
+
except SyntaxError as se:
|
|
108
|
+
syntax_errs.append(f"- '{bad_file.name}' line {se.lineno}: {se.msg}")
|
|
109
|
+
|
|
110
|
+
assert len(syntax_errs) == 1
|
|
111
|
+
assert "broken.py" in syntax_errs[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|