alpiecode 8.0.8__tar.gz → 9.0.0__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.
Files changed (47) hide show
  1. {alpiecode-8.0.8 → alpiecode-9.0.0}/PKG-INFO +1 -1
  2. {alpiecode-8.0.8 → alpiecode-9.0.0}/pyproject.toml +1 -1
  3. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/SOURCES.txt +3 -1
  5. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/openai_backend.py +97 -11
  7. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/client.py +19 -11
  8. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/context.py +65 -17
  9. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/extension/alpiecode.vsix +0 -0
  10. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/memory.py +48 -16
  11. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/prompt.py +2 -2
  12. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/tools.py +17 -3
  13. {alpiecode-8.0.8 → alpiecode-9.0.0}/test/test_phase1.py +54 -0
  14. alpiecode-9.0.0/test/test_phase2.py +231 -0
  15. alpiecode-9.0.0/test/test_phase3.py +154 -0
  16. {alpiecode-8.0.8 → alpiecode-9.0.0}/README.md +0 -0
  17. {alpiecode-8.0.8 → alpiecode-9.0.0}/setup.cfg +0 -0
  18. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode/__init__.py +0 -0
  19. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  20. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/entry_points.txt +0 -0
  21. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/requires.txt +0 -0
  22. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/top_level.txt +0 -0
  23. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/agent.py +0 -0
  24. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/__init__.py +0 -0
  25. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/base.py +0 -0
  26. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/local_backend.py +0 -0
  27. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/cache.py +0 -0
  28. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/cli.py +0 -0
  29. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/compaction.py +0 -0
  30. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/config.py +0 -0
  31. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/discovery.py +0 -0
  32. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/doctor.py +0 -0
  33. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/executor.py +0 -0
  34. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/git_ops.py +0 -0
  35. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/github.py +0 -0
  36. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/guardian.py +0 -0
  37. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/guardrails.py +0 -0
  38. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/ipython_ext.py +0 -0
  39. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/local_model.py +0 -0
  40. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/media.py +0 -0
  41. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/orchestrator.py +0 -0
  42. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/progress.py +0 -0
  43. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/rephraser.py +0 -0
  44. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/server.py +0 -0
  45. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/session.py +0 -0
  46. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/updater.py +0 -0
  47. {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/vscode_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.8
3
+ Version: 9.0.0
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "8.0.8"
7
+ version = "9.0.0"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.8
3
+ Version: 9.0.0
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -40,4 +40,6 @@ 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_phase1.py
43
+ test/test_phase1.py
44
+ test/test_phase2.py
45
+ 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__ = "8.0.8"
19
+ __version__ = "9.0.0"
20
20
 
21
21
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
22
22
  """Run an autonomous coding task programmatically."""
@@ -2,9 +2,103 @@
2
2
  OpenAI-compatible API inference backend with smart model resolution.
3
3
  """
4
4
 
5
+ import ast
6
+ import json
7
+ import logging
8
+ import re
5
9
  from typing import Any, List, Optional
6
10
  from openai import OpenAI, NotFoundError
7
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _repair_and_parse_json_arguments(raw_args: str) -> dict:
16
+ """
17
+ Robustly parse tool call arguments from potentially partial or malformed streaming strings.
18
+ Handles:
19
+ - Clean JSON
20
+ - Single-quoted JSON / Python dict strings via ast.literal_eval
21
+ - Truncated strings / missing closing quotes & braces from stream cutoffs
22
+ - Trailing commas
23
+ Returns a dict of parsed arguments. If unparseable, returns a dict preserving _raw_arguments
24
+ and _parse_error so the tool dispatcher can provide actionable error reporting.
25
+ """
26
+ if not raw_args or not raw_args.strip():
27
+ return {}
28
+
29
+ text = raw_args.strip()
30
+
31
+ # 1. Direct standard JSON
32
+ try:
33
+ parsed = json.loads(text)
34
+ if isinstance(parsed, dict):
35
+ return parsed
36
+ return {"value": parsed}
37
+ except Exception:
38
+ pass
39
+
40
+ # 2. Try ast.literal_eval (handles Python single quotes, True/False/None)
41
+ try:
42
+ parsed = ast.literal_eval(text)
43
+ if isinstance(parsed, dict):
44
+ return parsed
45
+ except Exception:
46
+ pass
47
+
48
+ # 3. Structural repair for streaming cutoffs / trailing commas
49
+ repaired = text
50
+
51
+ # Remove trailing commas like `..., }` or `..., ]`
52
+ repaired = re.sub(r",\s*([\}\]])", r"\1", repaired)
53
+
54
+ # If trailing comma at end: `{"a": 1,` -> `{"a": 1`
55
+ repaired = re.sub(r",\s*$", "", repaired)
56
+
57
+ # Check unclosed quotes: count non-escaped double quotes
58
+ quote_count = len(re.findall(r'(?<!\\)"', repaired))
59
+ if quote_count % 2 != 0:
60
+ repaired += '"'
61
+
62
+ # Balance unclosed brackets and braces
63
+ open_curly = repaired.count("{") - repaired.count("}")
64
+ open_square = repaired.count("[") - repaired.count("]")
65
+
66
+ if open_square > 0:
67
+ repaired += "]" * open_square
68
+ if open_curly > 0:
69
+ repaired += "}" * open_curly
70
+
71
+ try:
72
+ parsed = json.loads(repaired)
73
+ if isinstance(parsed, dict):
74
+ return parsed
75
+ except Exception:
76
+ pass
77
+
78
+ # 4. If still failing, attempt regex key-value extraction for simple {"key": "val"} forms
79
+ extracted = {}
80
+ pattern = r"""['"]([a-zA-Z0-9_-]+)['"]\s*:\s*(?:['"](.*?)['"]|([0-9.]+|true|false|null))"""
81
+ for kv in re.finditer(pattern, text, re.IGNORECASE):
82
+ k = kv.group(1)
83
+ v_str = kv.group(2)
84
+ v_lit = kv.group(3)
85
+ if v_str is not None:
86
+ extracted[k] = v_str
87
+ elif v_lit is not None:
88
+ try:
89
+ extracted[k] = json.loads(v_lit.lower())
90
+ except Exception:
91
+ extracted[k] = v_lit
92
+
93
+ if extracted:
94
+ return extracted
95
+
96
+ logger.warning("Failed to parse tool call arguments: %s", raw_args[:200])
97
+ return {
98
+ "_raw_arguments": raw_args,
99
+ "_parse_error": "Failed to parse streaming JSON arguments",
100
+ }
101
+
8
102
  from ..config import Config, get_shared_http_client, is_server_reachable
9
103
  from .base import ChatResponse, ToolCall
10
104
 
@@ -185,10 +279,7 @@ class OpenAIBackend:
185
279
  tool_calls = []
186
280
  for idx in sorted(tool_calls_dict.keys()):
187
281
  item = tool_calls_dict[idx]
188
- try:
189
- args = json.loads(item["arguments"])
190
- except Exception:
191
- args = {}
282
+ args = _repair_and_parse_json_arguments(item.get("arguments", ""))
192
283
  tool_calls.append(ToolCall(id=item["id"], name=item["name"], arguments=args))
193
284
 
194
285
  final_content_str = "".join(full_content).strip()
@@ -260,15 +351,10 @@ class OpenAIBackend:
260
351
 
261
352
  tool_calls = None
262
353
  if msg.tool_calls:
263
- import json
264
354
  tool_calls = []
265
355
  for tc in msg.tool_calls:
266
- try:
267
- args = json.loads(tc.function.arguments or "{}")
268
- except (json.JSONDecodeError, TypeError):
269
- args = {}
270
- if not isinstance(args, dict):
271
- args = {}
356
+ raw_args = tc.function.arguments or "{}"
357
+ args = _repair_and_parse_json_arguments(raw_args)
272
358
  tool_calls.append(
273
359
  ToolCall(
274
360
  id=tc.id,
@@ -102,31 +102,39 @@ class AlpieCodeClient:
102
102
  try:
103
103
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
104
104
  current_event_type = "message"
105
- current_data_str = ""
105
+ current_data_lines: List[str] = []
106
106
 
107
107
  for raw_line in resp:
108
- line = raw_line.decode("utf-8").strip()
108
+ line = raw_line.decode("utf-8").rstrip("\r\n")
109
109
  if not line:
110
- if current_data_str:
110
+ if current_data_lines:
111
+ raw_data = "\n".join(current_data_lines)
111
112
  try:
112
- event_data = json.loads(current_data_str)
113
+ event_data = json.loads(raw_data)
113
114
  except Exception:
114
- event_data = {"content": current_data_str}
115
+ event_data = {"content": raw_data}
115
116
  yield AgentEvent(type=current_event_type, data=event_data)
116
117
  current_event_type = "message"
117
- current_data_str = ""
118
+ current_data_lines = []
118
119
  continue
119
120
 
120
- if line.startswith("event:"):
121
+ if line.startswith(":"):
122
+ # SSE comment/keepalive ping - ignore
123
+ continue
124
+ elif line.startswith("event:"):
121
125
  current_event_type = line[6:].strip()
122
126
  elif line.startswith("data:"):
123
- current_data_str = line[5:].strip()
127
+ chunk = line[5:]
128
+ if chunk.startswith(" "):
129
+ chunk = chunk[1:]
130
+ current_data_lines.append(chunk)
124
131
 
125
- if current_data_str:
132
+ if current_data_lines:
133
+ raw_data = "\n".join(current_data_lines)
126
134
  try:
127
- event_data = json.loads(current_data_str)
135
+ event_data = json.loads(raw_data)
128
136
  except Exception:
129
- event_data = {"content": current_data_str}
137
+ event_data = {"content": raw_data}
130
138
  yield AgentEvent(type=current_event_type, data=event_data)
131
139
  except Exception as e:
132
140
  yield AgentEvent(type="error", data={"error": f"Server connection failed: {e}"})
@@ -6,6 +6,7 @@ and dynamically assembles token-budgeted context windows via build_context()
6
6
  for OpenAI-compatible chat completion endpoints.
7
7
  """
8
8
 
9
+ import copy
9
10
  import json
10
11
  import time
11
12
  from typing import Any, Dict, List, Optional
@@ -52,9 +53,19 @@ class ContextManager:
52
53
  def __init__(self, max_tokens: int = 262_144):
53
54
  self.max_tokens = max_tokens
54
55
  self._messages: List[dict] = []
56
+ self._raw_messages: List[dict] = []
55
57
  self._metadata: List[Dict[str, Any]] = []
56
58
  self._turn_counter: int = 0
57
59
  self._cached_summary: Optional[str] = None
60
+ self._cached_summary_len: int = 0
61
+ self._cached_context: Optional[List[dict]] = None
62
+
63
+ def invalidate_cache(self, reset_summary: bool = False) -> None:
64
+ """Invalidate cached context assembly."""
65
+ self._cached_context = None
66
+ if reset_summary:
67
+ self._cached_summary = None
68
+ self._cached_summary_len = 0
58
69
 
59
70
  def _estimate_single_message_tokens(self, msg: dict) -> int:
60
71
  """Estimate token count for an individual message."""
@@ -141,27 +152,37 @@ class ContextManager:
141
152
 
142
153
  def set_system_prompt(self, prompt: str) -> None:
143
154
  """Set or update the root system prompt (Layer 1)."""
155
+ self.invalidate_cache(reset_summary=True)
144
156
  sys_msg = {"role": "system", "content": prompt}
145
157
  if self._messages and self._messages[0].get("role") == "system":
146
- self._messages[0] = sys_msg
158
+ self._messages[0] = dict(sys_msg)
159
+ if self._raw_messages and self._raw_messages[0].get("role") == "system":
160
+ self._raw_messages[0] = copy.deepcopy(sys_msg)
161
+ else:
162
+ self._raw_messages.insert(0, copy.deepcopy(sys_msg))
147
163
  if self._metadata:
148
164
  self._metadata[0] = self._build_metadata_entry(sys_msg, importance="high")
149
165
  else:
150
- self._messages.insert(0, sys_msg)
166
+ self._messages.insert(0, dict(sys_msg))
167
+ self._raw_messages.insert(0, copy.deepcopy(sys_msg))
151
168
  self._metadata.insert(0, self._build_metadata_entry(sys_msg, importance="high"))
152
169
 
153
170
  def add_user_message(self, content: Any, metadata: Optional[dict] = None) -> None:
154
171
  """Append a user request and record metadata."""
172
+ self.invalidate_cache()
155
173
  self._turn_counter += 1
156
174
  msg = {"role": "user", "content": content}
157
- self._messages.append(msg)
175
+ self._messages.append(dict(msg))
176
+ self._raw_messages.append(copy.deepcopy(msg))
158
177
  self._metadata.append(self._build_metadata_entry(msg, custom=metadata))
159
178
 
160
179
  def add_assistant_response(self, response: ChatResponse, metadata: Optional[dict] = None) -> None:
161
180
  """Append an assistant response with tool calls and record metadata."""
162
181
  if response.tool_calls or response.content:
182
+ self.invalidate_cache()
163
183
  msg = _serialize_assistant_message(response)
164
- self._messages.append(msg)
184
+ self._messages.append(dict(msg))
185
+ self._raw_messages.append(copy.deepcopy(msg))
165
186
  tool_names = [tc.name for tc in response.tool_calls] if response.tool_calls else []
166
187
  custom_meta = metadata.copy() if metadata else {}
167
188
  custom_meta["tool_names"] = tool_names
@@ -176,12 +197,14 @@ class ContextManager:
176
197
  metadata: Optional[dict] = None,
177
198
  ) -> None:
178
199
  """Append a tool execution result and record metadata."""
200
+ self.invalidate_cache()
179
201
  msg = {
180
202
  "role": "tool",
181
203
  "tool_call_id": tool_call_id,
182
204
  "content": content,
183
205
  }
184
- self._messages.append(msg)
206
+ self._messages.append(dict(msg))
207
+ self._raw_messages.append(copy.deepcopy(msg))
185
208
  self._metadata.append(
186
209
  self._build_metadata_entry(msg, tool_name=tool_name, custom=metadata)
187
210
  )
@@ -190,7 +213,9 @@ class ContextManager:
190
213
  """
191
214
  Truncate large historical tool outputs from turns older than keep_last_turns.
192
215
  Preserves update_plan and short outputs.
216
+ Note: Operates on working _messages, while _raw_messages / raw_history remains pristine.
193
217
  """
218
+ self.invalidate_cache()
194
219
  assistant_indices = [
195
220
  i for i, m in enumerate(self._messages)
196
221
  if isinstance(m, dict) and m.get("role") == "assistant"
@@ -206,6 +231,8 @@ class ContextManager:
206
231
  # Never truncate update_plan output
207
232
  if "[Plan updated]" in content or "Plan Status:" in content or len(content) <= 300:
208
233
  continue
234
+ msg = dict(msg)
235
+ self._messages[i] = msg
209
236
  lines = content.splitlines()
210
237
  if len(lines) > 8:
211
238
  preview_start = "\n".join(lines[:3])
@@ -215,6 +242,7 @@ class ContextManager:
215
242
  msg["content"] = content[:150] + f"... [truncated {len(content)} chars]"
216
243
  # Update tokens in metadata
217
244
  if i < len(self._metadata):
245
+ self._metadata[i] = dict(self._metadata[i])
218
246
  self._metadata[i]["tokens"] = self._estimate_single_message_tokens(msg)
219
247
 
220
248
  def build_context(
@@ -230,6 +258,10 @@ class ContextManager:
230
258
  4. recent messages (intact tool-call pairs)
231
259
  5. current request (tail of conversation)
232
260
  """
261
+ is_default_params = (max_tokens is None or max_tokens == self.max_tokens) and recent_turns == 4
262
+ if is_default_params and self._cached_context is not None:
263
+ return [dict(m) for m in self._cached_context]
264
+
233
265
  if not self._messages:
234
266
  return []
235
267
 
@@ -276,10 +308,15 @@ class ContextManager:
276
308
  context_blocks.append(system_msg)
277
309
 
278
310
  if distant_messages:
279
- # Layer 2: Distant Summary
280
- summary_text = self._cached_summary or extract_conversation_summary(
281
- distant_messages, distant_metadata
282
- )
311
+ # Layer 2: Distant Summary (Lazy-cached)
312
+ if self._cached_summary is not None and len(distant_messages) == self._cached_summary_len:
313
+ summary_text = self._cached_summary
314
+ else:
315
+ summary_text = extract_conversation_summary(
316
+ distant_messages, distant_metadata
317
+ )
318
+ self._cached_summary = summary_text
319
+ self._cached_summary_len = len(distant_messages)
283
320
  # Layer 3: Relevant History Anchors
284
321
  relevant_anchors = select_relevant_history(
285
322
  distant_messages, distant_metadata
@@ -292,10 +329,16 @@ class ContextManager:
292
329
  supplemental_parts.append(relevant_anchors)
293
330
 
294
331
  if supplemental_parts:
295
- context_blocks.append({
296
- "role": "system",
297
- "content": "\n\n".join(supplemental_parts),
298
- })
332
+ supp_text = "=== CONVERSATION SUMMARY & RELEVANT HISTORY ===\n" + "\n\n".join(supplemental_parts)
333
+ if context_blocks and context_blocks[0].get("role") == "system":
334
+ # Merge into the existing primary system prompt to ensure strictly one system message at the beginning
335
+ context_blocks[0] = dict(context_blocks[0])
336
+ context_blocks[0]["content"] = (context_blocks[0].get("content") or "") + "\n\n" + supp_text
337
+ else:
338
+ context_blocks.insert(0, {
339
+ "role": "system",
340
+ "content": supp_text,
341
+ })
299
342
 
300
343
  # Ensure user query anchor: endpoint requires at least one 'user' message
301
344
  has_user_in_recent = any(m.get("role") == "user" for m in recent_messages)
@@ -325,7 +368,10 @@ class ContextManager:
325
368
  if estimate_tokens(context_blocks) > effective_limit:
326
369
  context_blocks = compact_messages(context_blocks)
327
370
 
328
- return context_blocks
371
+ if is_default_params:
372
+ self._cached_context = [dict(m) for m in context_blocks]
373
+
374
+ return [dict(m) for m in context_blocks]
329
375
 
330
376
  @property
331
377
  def messages(self) -> List[dict]:
@@ -335,18 +381,20 @@ class ContextManager:
335
381
  @messages.setter
336
382
  def messages(self, msgs: List[dict]) -> None:
337
383
  """Replace messages and rebuild metadata."""
338
- self._messages = list(msgs)
384
+ self.invalidate_cache(reset_summary=True)
385
+ self._messages = [dict(m) for m in msgs]
386
+ self._raw_messages = [copy.deepcopy(m) for m in msgs]
339
387
  self._rebuild_metadata()
340
388
 
341
389
  @property
342
390
  def all_messages(self) -> List[dict]:
343
391
  """Returns the complete, uncompressed historical messages."""
344
- return list(self._messages)
392
+ return [copy.deepcopy(m) for m in self._raw_messages]
345
393
 
346
394
  @property
347
395
  def raw_history(self) -> List[dict]:
348
396
  """Alias for all_messages."""
349
- return list(self._messages)
397
+ return [copy.deepcopy(m) for m in self._raw_messages]
350
398
 
351
399
  @property
352
400
  def metadata(self) -> List[Dict[str, Any]]:
@@ -47,36 +47,64 @@ def load_memories(workdir: Path) -> List[dict]:
47
47
  return []
48
48
 
49
49
 
50
- def save_memory(workdir: Path, content: str, memory_type: str = "learning") -> None:
50
+ def save_memories_batch(workdir: Path, entries: List[dict]) -> None:
51
51
  """
52
- Save a memory entry for a project.
52
+ Save multiple memory entries for a project in a single atomic disk write.
53
53
 
54
54
  Args:
55
55
  workdir: Project directory
56
- content: The memory content to save
57
- memory_type: Type of memory (learning, structure, command, pattern)
56
+ entries: List of dicts, each with 'content' and optional 'type'
58
57
  """
58
+ if not entries:
59
+ return
60
+
59
61
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
60
62
  path = _memory_path(workdir)
61
63
 
62
64
  existing = load_memories(workdir)
63
- existing.append({
64
- "content": content,
65
- "type": memory_type,
66
- "timestamp": time.time(),
67
- "workdir": str(workdir.resolve()),
68
- })
65
+ existing_contents = {m.get("content") for m in existing}
66
+ now = time.time()
67
+ workdir_str = str(workdir.resolve())
68
+
69
+ added = False
70
+ for entry in entries:
71
+ c = entry.get("content", "").strip()
72
+ if not c or c in existing_contents:
73
+ continue
74
+ existing.append({
75
+ "content": c,
76
+ "type": entry.get("type", "learning"),
77
+ "timestamp": now,
78
+ "workdir": workdir_str,
79
+ })
80
+ existing_contents.add(c)
81
+ added = True
82
+
83
+ if not added:
84
+ return
69
85
 
70
86
  # Keep only the last 20 memories per project (FIFO)
71
87
  if len(existing) > 20:
72
88
  existing = existing[-20:]
73
89
 
74
90
  path.write_text(json.dumps({
75
- "project": str(workdir.resolve()),
91
+ "project": workdir_str,
76
92
  "memories": existing,
77
93
  }, indent=2))
78
94
 
79
95
 
96
+ def save_memory(workdir: Path, content: str, memory_type: str = "learning") -> None:
97
+ """
98
+ Save a single memory entry for a project.
99
+
100
+ Args:
101
+ workdir: Project directory
102
+ content: The memory content to save
103
+ memory_type: Type of memory (learning, structure, command, pattern)
104
+ """
105
+ save_memories_batch(workdir, [{"content": content, "type": memory_type}])
106
+
107
+
80
108
  def format_memories_for_prompt(workdir: Path) -> Optional[str]:
81
109
  """
82
110
  Format memories into a string suitable for injection into the system prompt.
@@ -102,13 +130,14 @@ def format_memories_for_prompt(workdir: Path) -> Optional[str]:
102
130
  def extract_and_save_memories(workdir: Path, messages: list) -> None:
103
131
  """
104
132
  After a session ends, extract key learnings from the conversation
105
- and save them as memories.
133
+ and save them as memories in a single atomic batch.
106
134
 
107
135
  Scans tool results for commonly useful information like:
108
136
  - Successful build/test commands
109
137
  - Project structure (from list_files results)
110
138
  - Completion summaries
111
139
  """
140
+ to_save: List[dict] = []
112
141
  saved_cmds = set() # Avoid duplicate command memories
113
142
 
114
143
  for idx, msg in enumerate(messages):
@@ -137,20 +166,23 @@ def extract_and_save_memories(workdir: Path, messages: list) -> None:
137
166
  cmd = ""
138
167
  if cmd and cmd not in saved_cmds:
139
168
  if any(kw in cmd for kw in ["pytest", "test", "unittest", "npm run test", "cargo test", "go test"]):
140
- save_memory(workdir, f"Working test command: {cmd}", "command")
169
+ to_save.append({"content": f"Working test command: {cmd}", "type": "command"})
141
170
  saved_cmds.add(cmd)
142
171
  elif any(kw in cmd for kw in ["build", "compile", "g++", "gcc", "make", "cargo build", "npm run build"]):
143
- save_memory(workdir, f"Working build command: {cmd}", "command")
172
+ to_save.append({"content": f"Working build command: {cmd}", "type": "command"})
144
173
  saved_cmds.add(cmd)
145
174
  break
146
175
 
147
176
  # ── Save project structure from list_files / tree output ──
148
177
  if "├" in content or "└" in content:
149
178
  if len(content) < 2000:
150
- save_memory(workdir, f"Project structure:\n{content[:500]}", "structure")
179
+ to_save.append({"content": f"Project structure:\n{content[:500]}", "type": "structure"})
151
180
 
152
181
  # ── Save completion summaries ──
153
182
  if msg.get("role") == "assistant" and msg.get("content"):
154
183
  content = msg["content"]
155
184
  if content.strip().startswith("DONE"):
156
- save_memory(workdir, content.strip()[:200], "completion_summary")
185
+ to_save.append({"content": content.strip()[:200], "type": "completion_summary"})
186
+
187
+ if to_save:
188
+ save_memories_batch(workdir, to_save)
@@ -375,7 +375,7 @@ COMPLEXITY_CONFIG = {
375
375
 
376
376
  BASH_RULES = """\
377
377
  ## Shell Environment: Bash (Linux/macOS)
378
- - You are ALREADY in the project root directory. NEVER run `cd /home/...` or `cd /testbed`
378
+ - You are ALREADY in the project root directory. Run all commands directly without using `cd`
379
379
  - Use `&&` to chain commands
380
380
  - Use `export VAR=value` for environment variables
381
381
  - Use `python3` (not `python` which may be Python 2)
@@ -386,7 +386,7 @@ BASH_RULES = """\
386
386
 
387
387
  WSL_RULES = """\
388
388
  ## Shell Environment: WSL Bash (Windows Subsystem for Linux)
389
- - You are ALREADY in the project root directory. NEVER run `cd /home/...` or `cd /testbed`
389
+ - You are ALREADY in the project root directory. Run all commands directly without using `cd`
390
390
  - Use bash syntax: `&&` to chain, `export VAR=value` for env vars
391
391
  - Use `python3` (not `python`)
392
392
  - For testing python code, use `python3 -m unittest`
@@ -425,12 +425,17 @@ def _bash(workdir: Path, command: str) -> str:
425
425
  command = re.sub(r"(?:[^\s;&|]*/)?(?:python3?|py)(?:\.exe)?\s+-m\s+pip3?\s+install\b", f"{uv_bin} pip install", command)
426
426
  command = re.sub(r"(?:[^\s;&|]*/)?pip3?\s+install\b", f"{uv_bin} pip install", command)
427
427
 
428
+ # Strip hallucinated benchmark directory switches (e.g. cd /testbed && ..., cd /root && ...)
429
+ command = re.sub(r"^\s*cd\s+/(?:testbed|root|home/[^\s;&]+)\s*(&&|;)\s*", "", command)
430
+
428
431
  # Cross-platform shell resolution
429
432
  if shutil.which("bash"):
430
433
  shell_cmd = ["bash", "-c", command]
431
434
  elif os.name == "nt":
435
+ # PowerShell 5.1 compatibility: replace '&&' with ';'
436
+ ps_command = re.sub(r"\s+&&\s+", "; ", command)
432
437
  if shutil.which("powershell"):
433
- shell_cmd = ["powershell", "-NoProfile", "-Command", command]
438
+ shell_cmd = ["powershell", "-NoProfile", "-Command", ps_command]
434
439
  else:
435
440
  shell_cmd = ["cmd.exe", "/c", command]
436
441
  else:
@@ -607,10 +612,15 @@ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
607
612
  except (subprocess.TimeoutExpired, FileNotFoundError):
608
613
  pass
609
614
 
610
- # Fallback: os.walk
615
+ # Fallback: os.walk with directory pruning and early termination
616
+ prune_dirs = {
617
+ ".git", ".venv", "venv", "node_modules", "__pycache__",
618
+ "build", "dist", ".pytest_cache", ".ruff_cache", ".mypy_cache",
619
+ ".idea", ".vscode", ".next", ".nuxt", "target", "vendor",
620
+ }
611
621
  entries = []
612
622
  for root, dirs, files in os.walk(target):
613
- dirs[:] = [d for d in dirs if not d.startswith(".")]
623
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in prune_dirs]
614
624
  rel_root = os.path.relpath(root, target)
615
625
  depth = 0 if rel_root == "." else rel_root.count(os.sep) + 1
616
626
  if depth >= max_depth:
@@ -621,6 +631,10 @@ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
621
631
  continue
622
632
  rel_path = os.path.join(rel_root, fname) if rel_root != "." else fname
623
633
  entries.append(rel_path)
634
+ if len(entries) >= 200:
635
+ break
636
+ if len(entries) >= 200:
637
+ break
624
638
  return "\n".join(entries[:200]) or "(empty directory)"
625
639
 
626
640
 
@@ -6,11 +6,14 @@ Unit tests verifying Phase 1 stability fixes:
6
6
  - BUG-06: orchestrator.py backend immutability / thread safety
7
7
  - BUG-09: tools.py bash timeout error message alignment
8
8
  - BUG-11: compaction.py tool-call pairing integrity
9
+ - System message single-instance guarantee (prevents 400 'System message must be at the beginning')
10
+ - Benchmark directory stripping (prevents hallucinated cd /testbed failures)
9
11
  """
10
12
  import ast
11
13
  from pathlib import Path
12
14
  from unittest.mock import MagicMock, patch
13
15
  import json
16
+ import re
14
17
  import pytest
15
18
 
16
19
  from codeagent.executor import ToolExecutor, ToolCall, ToolResult
@@ -21,6 +24,7 @@ from codeagent.config import Config
21
24
  from codeagent.backends.base import ChatResponse
22
25
  from codeagent.backends.openai_backend import OpenAIBackend
23
26
  from codeagent.backends.local_backend import LocalBackend
27
+ from codeagent.context import ContextManager
24
28
 
25
29
 
26
30
  def test_bug01_executor_dead_code_removed():
@@ -203,3 +207,53 @@ def test_bug11_compaction_tool_pairing_guard():
203
207
  assert prev_msg.get("role") in ("assistant", "tool"), (
204
208
  f"Tool message at index {idx} was preceded by role '{prev_msg.get('role')}'"
205
209
  )
210
+
211
+
212
+ def test_strictly_one_system_message_on_distant_turns():
213
+ """Verify build_context produces strictly ONE system message at index 0, preventing 400 errors."""
214
+ cm = ContextManager(max_tokens=8192)
215
+ cm.set_system_prompt("Base system prompt for AlpieCode.")
216
+
217
+ # Add 10 turns (exceeding recent_turns=4, triggering distant turn extraction)
218
+ for i in range(10):
219
+ cm.add_user_message(f"User request {i}")
220
+ cm.add_assistant_response(ChatResponse(
221
+ content=f"Assistant response {i}",
222
+ reasoning=None,
223
+ tool_calls=[ToolCall(id=f"c_{i}", name="bash", arguments={"command": f"echo {i}"})]
224
+ ))
225
+ cm.add_tool_result(f"c_{i}", f"output {i}", tool_name="bash")
226
+
227
+ context = cm.build_context(recent_turns=4)
228
+
229
+ # Must have exactly one system message
230
+ system_messages = [m for m in context if m.get("role") == "system"]
231
+ assert len(system_messages) == 1, f"Expected exactly 1 system message, got {len(system_messages)}"
232
+
233
+ # It must be at index 0
234
+ assert context[0].get("role") == "system"
235
+
236
+ # It should include the merged conversation summary/history
237
+ assert "CONVERSATION SUMMARY" in context[0]["content"]
238
+
239
+
240
+ def test_bash_strips_testbed_hallucination(tmp_path):
241
+ """Verify tools.py _bash automatically strips 'cd /testbed &&' prefixes."""
242
+ import subprocess
243
+ from codeagent.tools import _bash
244
+
245
+ captured_cmds = []
246
+ def fake_run(cmd, **kwargs):
247
+ captured_cmds.append(cmd)
248
+ mock = MagicMock()
249
+ mock.stdout = "OK"
250
+ mock.stderr = ""
251
+ mock.returncode = 0
252
+ return mock
253
+
254
+ with patch("subprocess.run", side_effect=fake_run):
255
+ _bash(tmp_path, "cd /testbed && python -m pytest test_file.py")
256
+ assert len(captured_cmds) == 1
257
+ executed_str = " ".join(captured_cmds[0])
258
+ assert "/testbed" not in executed_str
259
+ assert "python -m pytest test_file.py" in executed_str
@@ -0,0 +1,231 @@
1
+ """
2
+ Unit tests verifying Phase 2 correctness and resilience fixes:
3
+ - BUG-03: context.py context cache and invalidation
4
+ - BUG-07: context.py raw history preservation and deep copy
5
+ - BUG-08: openai_backend.py partial stream JSON argument repair
6
+ - BUG-10: client.py multi-line SSE data accumulation and comment handling
7
+ """
8
+
9
+ import copy
10
+ import io
11
+ import json
12
+ from unittest.mock import MagicMock, patch
13
+ import pytest
14
+
15
+ from codeagent.context import ContextManager
16
+ from codeagent.backends.base import ChatResponse, ToolCall
17
+ from codeagent.backends.openai_backend import _repair_and_parse_json_arguments
18
+ from codeagent.client import AlpieCodeClient
19
+
20
+
21
+ # ==============================================================================
22
+ # BUG-07: Raw history preservation & deep copy
23
+ # ==============================================================================
24
+
25
+ def test_bug07_raw_history_never_mutated_by_rolling_compact():
26
+ """Verify rolling_compact_old_tools truncates working messages while raw_history remains pristine."""
27
+ cm = ContextManager(max_tokens=8192)
28
+ cm.set_system_prompt("System prompt")
29
+ cm.add_user_message("Task 1")
30
+
31
+ # Turn 1
32
+ cm.add_assistant_response(ChatResponse(
33
+ content="Running tool",
34
+ reasoning=None,
35
+ tool_calls=[ToolCall(id="call_1", name="bash", arguments={"command": "cat long_file"})]
36
+ ))
37
+ long_tool_output = "\n".join([f"Line {i}: This is long output that should be truncated in compaction." for i in range(50)])
38
+ cm.add_tool_result("call_1", long_tool_output, tool_name="bash")
39
+
40
+ # Turn 2
41
+ cm.add_assistant_response(ChatResponse(
42
+ content="Running second tool",
43
+ reasoning=None,
44
+ tool_calls=[ToolCall(id="call_2", name="bash", arguments={"command": "echo done"})]
45
+ ))
46
+ cm.add_tool_result("call_2", "short output", tool_name="bash")
47
+
48
+ # Turn 3
49
+ cm.add_assistant_response(ChatResponse(
50
+ content="Final turn",
51
+ reasoning=None,
52
+ tool_calls=[]
53
+ ))
54
+
55
+ # Before compaction, verify raw history contains the full 50 lines
56
+ raw_before = cm.all_messages
57
+ tool_msg_raw = [m for m in raw_before if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
58
+ assert tool_msg_raw["content"] == long_tool_output
59
+
60
+ # Run rolling compact keeping only the last turn
61
+ cm.rolling_compact_old_tools(keep_last_turns=1)
62
+
63
+ # In active messages, call_1 output should now be truncated
64
+ active_msgs = cm._messages
65
+ tool_msg_active = [m for m in active_msgs if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
66
+ assert "[Output: 50 lines truncated for brevity]" in tool_msg_active["content"]
67
+
68
+ # In all_messages and raw_history, it MUST remain completely intact
69
+ raw_after = cm.all_messages
70
+ tool_msg_raw_after = [m for m in raw_after if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
71
+ assert tool_msg_raw_after["content"] == long_tool_output
72
+
73
+ raw_hist_after = cm.raw_history
74
+ tool_msg_hist_after = [m for m in raw_hist_after if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
75
+ assert tool_msg_hist_after["content"] == long_tool_output
76
+
77
+
78
+ def test_bug07_raw_history_deep_copy_isolation():
79
+ """Verify mutating dictionaries in the returned raw_history list cannot corrupt ContextManager internals."""
80
+ cm = ContextManager(max_tokens=8192)
81
+ cm.set_system_prompt("System prompt")
82
+ cm.add_user_message("Original user message")
83
+
84
+ history = cm.raw_history
85
+ assert len(history) == 2
86
+
87
+ # Mutate the returned dictionary
88
+ history[1]["content"] = "CORRUPTED_IN_PLACE"
89
+
90
+ # Fetching history again should yield the unmodified original content
91
+ fresh_history = cm.raw_history
92
+ assert fresh_history[1]["content"] == "Original user message"
93
+
94
+
95
+ # ==============================================================================
96
+ # BUG-03: Context cache and invalidation
97
+ # ==============================================================================
98
+
99
+ def test_bug03_context_cache_and_invalidation():
100
+ """Verify build_context caches results and invalidates properly on every mutation."""
101
+ cm = ContextManager(max_tokens=8192)
102
+ assert cm._cached_context is None
103
+
104
+ cm.set_system_prompt("System prompt")
105
+ cm.add_user_message("User message 1")
106
+ assert cm._cached_context is None
107
+
108
+ # First access to messages populates cache
109
+ msgs_1 = cm.messages
110
+ assert cm._cached_context is not None
111
+ cached_ref = cm._cached_context
112
+
113
+ # Second access returns cached content without rebuilding
114
+ msgs_2 = cm.messages
115
+ assert msgs_1 == msgs_2
116
+ assert cm._cached_context is cached_ref
117
+
118
+ # Adding assistant response must invalidate cache
119
+ cm.add_assistant_response(ChatResponse(content="Response 1", reasoning=None, tool_calls=[]))
120
+ assert cm._cached_context is None
121
+
122
+ # Accessing messages repopulates cache
123
+ msgs_3 = cm.messages
124
+ assert len(msgs_3) == 3
125
+ assert cm._cached_context is not None
126
+
127
+ # Adding tool result must invalidate cache
128
+ cm.add_tool_result("id_1", "output 1")
129
+ assert cm._cached_context is None
130
+
131
+ # Adding user message must invalidate cache
132
+ cm.messages # populate
133
+ assert cm._cached_context is not None
134
+ cm.add_user_message("User message 2")
135
+ assert cm._cached_context is None
136
+
137
+ # Reassigning messages setter must invalidate cache
138
+ cm.messages # populate
139
+ assert cm._cached_context is not None
140
+ cm.messages = [{"role": "system", "content": "New sys"}]
141
+ assert cm._cached_context is None
142
+ assert len(cm.messages) == 1
143
+
144
+
145
+ # ==============================================================================
146
+ # BUG-08: Partial stream JSON argument repair
147
+ # ==============================================================================
148
+
149
+ def test_bug08_partial_json_repair_clean():
150
+ """Verify clean JSON parses directly."""
151
+ res = _repair_and_parse_json_arguments('{"command": "pytest -v", "timeout": 30}')
152
+ assert res == {"command": "pytest -v", "timeout": 30}
153
+
154
+
155
+ def test_bug08_partial_json_repair_truncated_quote_and_brace():
156
+ """Verify truncated JSON missing closing quotes and braces is automatically repaired."""
157
+ # Split mid-string: '{"command": "python -m pytest test.py'
158
+ res = _repair_and_parse_json_arguments('{"command": "python -m pytest test.py')
159
+ assert res == {"command": "python -m pytest test.py"}
160
+
161
+ # Missing closing brace only: '{"path": "foo.py", "content": "abc"}'
162
+ res2 = _repair_and_parse_json_arguments('{"path": "foo.py", "content": "abc"')
163
+ assert res2 == {"path": "foo.py", "content": "abc"}
164
+
165
+
166
+ def test_bug08_partial_json_repair_trailing_comma():
167
+ """Verify trailing commas are stripped before parsing."""
168
+ res = _repair_and_parse_json_arguments('{"path": "foo.py",}')
169
+ assert res == {"path": "foo.py"}
170
+
171
+
172
+ def test_bug08_partial_json_repair_python_syntax():
173
+ """Verify Python single-quoted dict strings are parsed via literal_eval."""
174
+ res = _repair_and_parse_json_arguments("{'command': 'ls -la', 'active': True}")
175
+ assert res == {"command": "ls -la", "active": True}
176
+
177
+
178
+ def test_bug08_partial_json_unparseable_preserves_error():
179
+ """Verify completely unparseable input preserves raw arguments and parse error rather than silent empty dict."""
180
+ res = _repair_and_parse_json_arguments("invalid garbage text without json structure")
181
+ assert "_raw_arguments" in res
182
+ assert res["_raw_arguments"] == "invalid garbage text without json structure"
183
+ assert "_parse_error" in res
184
+
185
+
186
+ def test_bug08_partial_json_empty():
187
+ """Verify empty or whitespace string returns empty dict."""
188
+ assert _repair_and_parse_json_arguments("") == {}
189
+ assert _repair_and_parse_json_arguments(" ") == {}
190
+
191
+
192
+ # ==============================================================================
193
+ # BUG-10: Multi-line SSE parser accumulation
194
+ # ==============================================================================
195
+
196
+ def test_bug10_multiline_sse_parser():
197
+ """Verify stream_chat correctly accumulates multi-line data fields and ignores comments."""
198
+ # Construct raw SSE stream simulating multi-line JSON payload and SSE comments
199
+ sse_payload = (
200
+ b": keepalive ping\n"
201
+ b"event: message\n"
202
+ b"data: {\"role\": \"assistant\",\n"
203
+ b"data: \"content\": \"hello\\nworld\"}\n"
204
+ b"\n"
205
+ b": another comment\n"
206
+ b"event: token\n"
207
+ b"data: {\"delta\": \" next token\"}\n"
208
+ b"\n"
209
+ )
210
+
211
+ mock_resp = MagicMock()
212
+ mock_resp.__enter__.return_value = io.BytesIO(sse_payload)
213
+ mock_resp.__exit__.return_value = False
214
+
215
+ client = AlpieCodeClient(base_url="http://localhost:8000")
216
+
217
+ with patch("urllib.request.urlopen", return_value=mock_resp):
218
+ events = list(client.stream_chat(task="test multiline"))
219
+
220
+ assert len(events) == 2
221
+
222
+ # First event: multi-line message
223
+ evt1 = events[0]
224
+ assert evt1.type == "message"
225
+ assert evt1.data["role"] == "assistant"
226
+ assert evt1.data["content"] == "hello\nworld"
227
+
228
+ # Second event: token
229
+ evt2 = events[1]
230
+ assert evt2.type == "token"
231
+ assert evt2.data["delta"] == " next token"
@@ -0,0 +1,154 @@
1
+ """
2
+ Unit tests verifying Phase 3 performance optimizations:
3
+ - PERF-01: context.py distant summary lazy caching
4
+ - PERF-06: tools.py _list_files directory pruning and early termination
5
+ - PERF-05: memory.py batched disk writes and deduplication
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock, patch
11
+ import pytest
12
+
13
+ from codeagent.context import ContextManager
14
+ from codeagent.backends.base import ChatResponse, ToolCall
15
+ from codeagent.tools import _list_files
16
+ import codeagent.memory as memory_mod
17
+
18
+
19
+ # ==============================================================================
20
+ # PERF-01: Distant summary lazy caching
21
+ # ==============================================================================
22
+
23
+ def test_perf01_summary_lazy_caching():
24
+ """Verify distant summary is lazy-cached and not re-computed when distant count is unchanged."""
25
+ cm = ContextManager(max_tokens=8192)
26
+ cm.set_system_prompt("Base system prompt")
27
+
28
+ # Add 6 turns
29
+ for i in range(6):
30
+ cm.add_user_message(f"User turn {i}")
31
+ cm.add_assistant_response(ChatResponse(
32
+ content=f"Assistant turn {i}",
33
+ reasoning=None,
34
+ tool_calls=[ToolCall(id=f"c_{i}", name="bash", arguments={"command": f"echo {i}"})]
35
+ ))
36
+ cm.add_tool_result(f"c_{i}", f"output {i}", tool_name="bash")
37
+
38
+ # First build with recent_turns=2 (creating distant messages)
39
+ context_1 = cm.build_context(recent_turns=2)
40
+ assert cm._cached_summary is not None
41
+ cached_summary = cm._cached_summary
42
+ assert cm._cached_summary_len > 0
43
+
44
+ # Next call with same distant turns should reuse the cached summary without calling extract_conversation_summary
45
+ with patch("codeagent.context.extract_conversation_summary", side_effect=RuntimeError("Should NOT be called")):
46
+ context_2 = cm.build_context(recent_turns=2)
47
+ assert context_2 == context_1
48
+ assert cm._cached_summary == cached_summary
49
+
50
+
51
+ # ==============================================================================
52
+ # PERF-06: _list_files directory pruning and short-circuit
53
+ # ==============================================================================
54
+
55
+ def test_perf06_list_files_pruning(tmp_path):
56
+ """Verify _list_files prunes node_modules, .venv, __pycache__, and hidden dirs."""
57
+ # Create valid project file
58
+ src_dir = tmp_path / "src"
59
+ src_dir.mkdir()
60
+ (src_dir / "index.py").write_text("print('hello')", encoding="utf-8")
61
+
62
+ # Create directories that MUST be pruned
63
+ node_modules = tmp_path / "node_modules" / "pkg"
64
+ node_modules.mkdir(parents=True)
65
+ (node_modules / "index.js").write_text("module.exports = {}", encoding="utf-8")
66
+
67
+ venv = tmp_path / ".venv" / "lib"
68
+ venv.mkdir(parents=True)
69
+ (venv / "site.py").write_text("# venv file", encoding="utf-8")
70
+
71
+ pycache = tmp_path / "__pycache__"
72
+ pycache.mkdir()
73
+ (pycache / "index.pyc").write_text("bytes", encoding="utf-8")
74
+
75
+ result = _list_files(tmp_path, max_depth=4)
76
+
77
+ # Valid file present
78
+ assert "src/index.py" in result or "src\\index.py" in result
79
+
80
+ # Pruned directories absent
81
+ assert "node_modules" not in result
82
+ assert ".venv" not in result
83
+ assert "__pycache__" not in result
84
+
85
+
86
+ def test_perf06_list_files_short_circuit(tmp_path):
87
+ """Verify _list_files stops walking after collecting 200 entries."""
88
+ # Create 250 files
89
+ for i in range(250):
90
+ (tmp_path / f"file_{i:03d}.txt").write_text("content", encoding="utf-8")
91
+
92
+ result = _list_files(tmp_path, max_depth=2)
93
+ lines = result.splitlines()
94
+ assert len(lines) <= 200
95
+
96
+
97
+ # ==============================================================================
98
+ # PERF-05: Batched memory disk writes
99
+ # ==============================================================================
100
+
101
+ def test_perf05_batched_memory_saves(tmp_path, monkeypatch):
102
+ """Verify save_memories_batch performs a single atomic disk update with deduplication and FIFO limits."""
103
+ mem_dir = tmp_path / "memories"
104
+ monkeypatch.setattr(memory_mod, "MEMORY_DIR", mem_dir)
105
+
106
+ workdir = tmp_path / "project"
107
+ workdir.mkdir()
108
+
109
+ entries = [
110
+ {"content": "Working test command: pytest -v", "type": "command"},
111
+ {"content": "Working build command: npm run build", "type": "command"},
112
+ {"content": "Working test command: pytest -v", "type": "command"}, # Duplicate
113
+ ]
114
+
115
+ memory_mod.save_memories_batch(workdir, entries)
116
+
117
+ saved = memory_mod.load_memories(workdir)
118
+ assert len(saved) == 2 # Deduplicated from 3 to 2
119
+ contents = [m["content"] for m in saved]
120
+ assert "Working test command: pytest -v" in contents
121
+ assert "Working build command: npm run build" in contents
122
+
123
+
124
+ def test_perf05_extract_and_save_memories_single_batch(tmp_path, monkeypatch):
125
+ """Verify extract_and_save_memories aggregates all discovered items into exactly one batch call."""
126
+ mem_dir = tmp_path / "memories"
127
+ monkeypatch.setattr(memory_mod, "MEMORY_DIR", mem_dir)
128
+
129
+ workdir = tmp_path / "project"
130
+ workdir.mkdir()
131
+
132
+ messages = [
133
+ {
134
+ "role": "assistant",
135
+ "tool_calls": [
136
+ {"id": "c1", "type": "function", "function": {"name": "bash", "arguments": json.dumps({"command": "pytest -v"})}}
137
+ ]
138
+ },
139
+ {
140
+ "role": "tool",
141
+ "tool_call_id": "c1",
142
+ "content": '{"exit_code": 0, "stdout": "All tests passed"}'
143
+ },
144
+ {
145
+ "role": "assistant",
146
+ "content": "DONE: Task completed successfully"
147
+ }
148
+ ]
149
+
150
+ with patch.object(memory_mod, "save_memories_batch", wraps=memory_mod.save_memories_batch) as mock_batch:
151
+ memory_mod.extract_and_save_memories(workdir, messages)
152
+ assert mock_batch.call_count == 1
153
+ args, _ = mock_batch.call_args
154
+ assert len(args[1]) == 2 # 1 test command + 1 completion summary
File without changes
File without changes