alpiecode 8.0.7__tar.gz → 8.0.8__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 (45) hide show
  1. {alpiecode-8.0.7 → alpiecode-8.0.8}/PKG-INFO +1 -1
  2. {alpiecode-8.0.7 → alpiecode-8.0.8}/pyproject.toml +1 -1
  3. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode.egg-info/SOURCES.txt +2 -1
  5. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/__init__.py +1 -2
  6. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/agent.py +74 -5
  7. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/cli.py +0 -49
  8. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/compaction.py +9 -0
  9. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/context.py +8 -0
  10. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/executor.py +0 -59
  11. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/orchestrator.py +23 -17
  12. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/tools.py +1 -1
  13. alpiecode-8.0.8/test/test_phase1.py +205 -0
  14. {alpiecode-8.0.7 → alpiecode-8.0.8}/README.md +0 -0
  15. {alpiecode-8.0.7 → alpiecode-8.0.8}/setup.cfg +0 -0
  16. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode/__init__.py +0 -0
  17. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  18. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode.egg-info/entry_points.txt +0 -0
  19. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode.egg-info/requires.txt +0 -0
  20. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/alpiecode.egg-info/top_level.txt +0 -0
  21. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/backends/__init__.py +0 -0
  22. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/backends/base.py +0 -0
  23. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/backends/local_backend.py +0 -0
  24. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/backends/openai_backend.py +0 -0
  25. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/cache.py +0 -0
  26. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/client.py +0 -0
  27. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/config.py +0 -0
  28. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/discovery.py +0 -0
  29. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/doctor.py +0 -0
  30. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/extension/alpiecode.vsix +0 -0
  31. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/git_ops.py +0 -0
  32. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/github.py +0 -0
  33. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/guardian.py +0 -0
  34. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/guardrails.py +0 -0
  35. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/ipython_ext.py +0 -0
  36. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/local_model.py +0 -0
  37. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/media.py +0 -0
  38. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/memory.py +0 -0
  39. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/progress.py +0 -0
  40. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/prompt.py +0 -0
  41. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/rephraser.py +0 -0
  42. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/server.py +0 -0
  43. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/session.py +0 -0
  44. {alpiecode-8.0.7 → alpiecode-8.0.8}/src/codeagent/updater.py +0 -0
  45. {alpiecode-8.0.7 → alpiecode-8.0.8}/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.7
3
+ Version: 8.0.8
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.7"
7
+ version = "8.0.8"
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.7
3
+ Version: 8.0.8
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
@@ -39,4 +39,5 @@ src/codeagent/backends/__init__.py
39
39
  src/codeagent/backends/base.py
40
40
  src/codeagent/backends/local_backend.py
41
41
  src/codeagent/backends/openai_backend.py
42
- src/codeagent/extension/alpiecode.vsix
42
+ src/codeagent/extension/alpiecode.vsix
43
+ test/test_phase1.py
@@ -16,8 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "8.0.7"
20
-
19
+ __version__ = "8.0.8"
21
20
 
22
21
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
23
22
  """Run an autonomous coding task programmatically."""
@@ -11,6 +11,7 @@ Delegates agent orchestration to AgentOrchestrator and handles Rich terminal UI
11
11
 
12
12
  import json
13
13
  import subprocess
14
+ import sys
14
15
  from pathlib import Path
15
16
  from typing import Any, Dict, List, Optional
16
17
 
@@ -249,9 +250,43 @@ def run_agent(
249
250
 
250
251
  current_turn = 0
251
252
  last_discovery = {}
253
+ has_streamed_tokens = False
254
+ has_printed_anything = False
252
255
 
253
256
  for event in event_stream:
254
- if event.type == "discovery" and verbose:
257
+ if event.type == "token" and verbose:
258
+ delta = event.data.get("delta", "")
259
+ if delta:
260
+ if not has_streamed_tokens:
261
+ sys.stdout.write("\n")
262
+ has_streamed_tokens = True
263
+ sys.stdout.write(delta)
264
+ sys.stdout.flush()
265
+ has_printed_anything = True
266
+
267
+ elif event.type == "thinking_start" and verbose:
268
+ if debug:
269
+ if HAS_RICH:
270
+ console.print("\n[dim italic]Thinking...[/dim italic]", style="dim")
271
+ else:
272
+ sys.stdout.write("\nThinking...\n")
273
+ sys.stdout.flush()
274
+
275
+ elif event.type == "thinking_delta" and verbose and debug:
276
+ delta = event.data.get("delta", "")
277
+ if delta:
278
+ sys.stdout.write(delta)
279
+ sys.stdout.flush()
280
+
281
+ elif event.type == "thinking_end" and verbose and debug:
282
+ duration = event.data.get("duration", 0)
283
+ if HAS_RICH:
284
+ console.print(f" [dim]({duration}s)[/dim]\n")
285
+ else:
286
+ sys.stdout.write(f" ({duration}s)\n")
287
+ sys.stdout.flush()
288
+
289
+ elif event.type == "discovery" and verbose:
255
290
  last_discovery = event.data
256
291
  if debug and HAS_RICH:
257
292
  console.print(Panel(
@@ -335,7 +370,12 @@ def run_agent(
335
370
  _print_reasoning(event.data["content"])
336
371
 
337
372
  elif event.type == "tool_call" and verbose:
373
+ if has_streamed_tokens:
374
+ sys.stdout.write("\n")
375
+ sys.stdout.flush()
376
+ has_streamed_tokens = False
338
377
  _print_tool_call(event.data["turn"], event.data["name"], event.data["arguments"])
378
+ has_printed_anything = True
339
379
 
340
380
  elif event.type == "tool_result":
341
381
  if verbose:
@@ -343,7 +383,13 @@ def run_agent(
343
383
  _checkpoint(workdir, f"checkpoint: turn {event.data['turn']}")
344
384
 
345
385
  elif event.type == "message" and verbose:
346
- _print_assistant_message(event.data["content"])
386
+ if has_streamed_tokens:
387
+ sys.stdout.write("\n")
388
+ sys.stdout.flush()
389
+ has_streamed_tokens = False
390
+ else:
391
+ _print_assistant_message(event.data["content"])
392
+ has_printed_anything = True
347
393
  _checkpoint(workdir, "checkpoint: response")
348
394
 
349
395
  elif event.type == "fallback" and verbose:
@@ -393,7 +439,13 @@ def run_agent(
393
439
  print(f"\n🛑 Safety ceiling ({event.data['ceiling']}) reached.")
394
440
 
395
441
  elif event.type == "done":
396
- summary = event.data.get("summary", "")
442
+ if has_streamed_tokens:
443
+ sys.stdout.write("\n\n")
444
+ sys.stdout.flush()
445
+ has_streamed_tokens = False
446
+ elif not has_printed_anything and event.data.get("summary"):
447
+ _print_assistant_message(event.data["summary"])
448
+ has_printed_anything = True
397
449
  _checkpoint(workdir, "checkpoint: done")
398
450
  if debug and HAS_RICH:
399
451
  console.rule("[bold green]✅ Complete[/bold green]")
@@ -461,8 +513,16 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
461
513
  console.print("Goodbye! 👋")
462
514
  break
463
515
 
516
+ has_streamed_chat = False
464
517
  for event in orchestrator.run_task(session, user_input, cfg):
465
- if event.type == "turn_start" and debug:
518
+ if event.type == "token":
519
+ delta = event.data.get("delta", "")
520
+ if delta:
521
+ sys.stdout.write(delta)
522
+ sys.stdout.flush()
523
+ has_streamed_chat = True
524
+
525
+ elif event.type == "turn_start" and debug:
466
526
  if HAS_RICH:
467
527
  console.print(f"[dim]── Step {event.data['turn']} ──[/dim]")
468
528
  else:
@@ -480,7 +540,11 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
480
540
  _checkpoint(workdir, f"checkpoint: chat turn {event.data['turn']}")
481
541
 
482
542
  elif event.type == "message":
483
- _print_assistant_message(event.data["content"])
543
+ if has_streamed_chat:
544
+ sys.stdout.write("\n")
545
+ has_streamed_chat = False
546
+ else:
547
+ _print_assistant_message(event.data["content"])
484
548
  _checkpoint(workdir, "checkpoint: done")
485
549
 
486
550
  elif event.type == "error":
@@ -490,4 +554,9 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
490
554
  print(f"❌ Model error: {event.data['error']}")
491
555
 
492
556
  elif event.type == "done":
557
+ if has_streamed_chat:
558
+ sys.stdout.write("\n")
559
+ has_streamed_chat = False
560
+ elif event.data.get("summary"):
561
+ _print_assistant_message(event.data["summary"])
493
562
  break
@@ -219,10 +219,6 @@ def main():
219
219
  undo_last_session(Path(args.workdir).resolve())
220
220
  return
221
221
 
222
- elif args.command == "doctor":
223
- from .doctor import run_doctor
224
- sys.exit(run_doctor())
225
-
226
222
  elif args.command == "explain":
227
223
  target = args.target
228
224
  target_path = Path(args.workdir) / target if not Path(target).is_absolute() else Path(target)
@@ -254,51 +250,6 @@ def main():
254
250
  debug=getattr(args, "debug", False),
255
251
  )
256
252
 
257
- elif args.command == "diff":
258
- workdir = Path(args.workdir).resolve()
259
- # Show git diff since the first checkpoint
260
- result = subprocess.run(
261
- ["git", "log", "--oneline", "--all"],
262
- cwd=workdir, capture_output=True, text=True
263
- )
264
- if result.returncode != 0:
265
- print("Not a git repository or no commits found.")
266
- return
267
-
268
- # Find the start checkpoint
269
- log_lines = result.stdout.strip().splitlines()
270
- start_sha = None
271
- for line in reversed(log_lines):
272
- if "checkpoint: start" in line:
273
- start_sha = line.split()[0]
274
- break
275
-
276
- if not start_sha:
277
- print("No AlpieCode checkpoint found. Run a task first.")
278
- return
279
-
280
- diff_result = subprocess.run(
281
- ["git", "diff", start_sha, "HEAD", "--stat"],
282
- cwd=workdir, capture_output=True, text=True
283
- )
284
- print(f"Changes since AlpieCode started (from {start_sha}):\n")
285
- print(diff_result.stdout)
286
-
287
- # Also show the full diff
288
- full_diff = subprocess.run(
289
- ["git", "diff", start_sha, "HEAD"],
290
- cwd=workdir, capture_output=True, text=True
291
- )
292
- if full_diff.stdout:
293
- try:
294
- from rich.console import Console
295
- from rich.syntax import Syntax
296
- console = Console()
297
- syntax = Syntax(full_diff.stdout, "diff", theme="monokai")
298
- console.print(syntax)
299
- except ImportError:
300
- print(full_diff.stdout)
301
-
302
253
 
303
254
  if __name__ == "__main__":
304
255
  main()
@@ -240,6 +240,15 @@ def compact_messages(messages: List[dict]) -> List[dict]:
240
240
 
241
241
  # Split into old and recent
242
242
  cutoff = len(messages) - KEEP_RECENT_TURNS
243
+
244
+ # ── Tool-Call Pairing Guard ──
245
+ # If cutoff lands on a 'tool' message, move it backwards to include
246
+ # the preceding 'assistant' message that owns the tool_call_id.
247
+ # This prevents OpenAI API 400 errors ("missing tool result for tool_call_id").
248
+ min_cutoff = 1 if system else 0
249
+ while cutoff > min_cutoff and messages[cutoff].get("role") == "tool":
250
+ cutoff -= 1
251
+
243
252
  old_messages = messages[1:cutoff] if system else messages[:cutoff]
244
253
  recent_messages = messages[cutoff:]
245
254
 
@@ -297,6 +297,14 @@ class ContextManager:
297
297
  "content": "\n\n".join(supplemental_parts),
298
298
  })
299
299
 
300
+ # Ensure user query anchor: endpoint requires at least one 'user' message
301
+ has_user_in_recent = any(m.get("role") == "user" for m in recent_messages)
302
+ if not has_user_in_recent:
303
+ for m in distant_messages:
304
+ if m.get("role") == "user":
305
+ context_blocks.append(dict(m))
306
+ break
307
+
300
308
  # ── Layer 4 & 5: Recent Messages and Current Request ──
301
309
  if len(recent_messages) > 6:
302
310
  for i in range(len(recent_messages) - 4):
@@ -346,62 +346,3 @@ class ToolExecutor:
346
346
  )
347
347
 
348
348
  return [results_map[tc.id] for tc in tool_calls if tc.id in results_map]
349
-
350
- # Sequential execution
351
- for tc in tool_calls:
352
- if on_tool_start:
353
- on_tool_start(tc.name, tc.arguments)
354
- t0 = time.monotonic()
355
- # Defensive: ensure arguments is always a dict
356
- safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
357
- fn = self.dispatch.get(tc.name)
358
- if fn:
359
- try:
360
- res_str = str(fn(safe_args))
361
- except Exception as e:
362
- res_str = f"error: {e}"
363
- else:
364
- res_str = f"error: Unknown tool '{tc.name}'"
365
- elapsed = (time.monotonic() - t0) * 1000
366
-
367
- # Tool loop detection guard
368
- call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
369
- self.tool_call_history.append(call_sig)
370
- repeat_count = sum(1 for item in self.tool_call_history[-5:] if item == call_sig)
371
-
372
- if repeat_count >= 3:
373
- res_str += (
374
- f"\n\n🛑 REPEATED TOOL CALL LOOP DETECTED (attempt #{repeat_count}). "
375
- f"You have already executed '{tc.name}' with these exact parameters {repeat_count} times in a row. "
376
- "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
377
- )
378
-
379
- # Compilation failure recovery hint
380
- if tc.name == "bash":
381
- cmd = tc.arguments.get("command", "")
382
- is_compile = any(kw in cmd for kw in ["g++", "gcc", "clang", "make", "cmake", "cargo build", "rustc"])
383
- if is_compile and "exit_code" in str(res_str):
384
- try:
385
- result_data = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("⚠️") else res_str)
386
- if result_data.get("exit_code", 0) != 0:
387
- compile_key = cmd.strip()
388
- self.compile_fail_counts[compile_key] = self.compile_fail_counts.get(compile_key, 0) + 1
389
- if self.compile_fail_counts[compile_key] >= 3:
390
- res_str += (
391
- "\n\n🛑 REPEATED COMPILATION FAILURE (attempt "
392
- f"#{self.compile_fail_counts[compile_key]}). "
393
- "STOP making blind edits. Re-read the ENTIRE source file with "
394
- "read_file to understand its full structure, then fix ALL errors "
395
- "comprehensively in one edit."
396
- )
397
- else:
398
- self.compile_fail_counts.pop(cmd.strip(), None)
399
- except (json.JSONDecodeError, ValueError):
400
- pass
401
-
402
- if on_tool_end:
403
- on_tool_end(tc.name, res_str)
404
-
405
- results.append(ToolResult(tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed))
406
-
407
- return results
@@ -118,6 +118,7 @@ class AgentOrchestrator:
118
118
  # Track touched files and executed commands for Antigravity Walkthrough
119
119
  session_touched_files = set()
120
120
  session_executed_commands = []
121
+ verification_nudged = False # Per-task local flag (prevents cross-session state leak)
121
122
 
122
123
  # ── Response cache check ──
123
124
  is_cacheable = not any([image_path, video_path, url, github_repo])
@@ -142,11 +143,12 @@ class AgentOrchestrator:
142
143
  yield AgentEvent("done", {"summary": cached["response"]})
143
144
  return
144
145
 
145
- # ── Dynamic backend re-check ──
146
- if isinstance(self.backend, LocalBackend) and is_server_reachable(cfg.base_url, timeout=1.5):
147
- self.backend = OpenAIBackend(cfg)
146
+ # ── Dynamic backend re-check (thread-safe: use task-local backend) ──
147
+ task_backend = self.backend
148
+ if isinstance(task_backend, LocalBackend) and is_server_reachable(cfg.base_url, timeout=1.5):
149
+ task_backend = OpenAIBackend(cfg)
148
150
 
149
- is_offline = not self.backend.is_available or isinstance(self.backend, LocalBackend)
151
+ is_offline = not task_backend.is_available or isinstance(task_backend, LocalBackend)
150
152
  session.is_offline = is_offline
151
153
 
152
154
  # ── Configure tools & system prompt based on complexity ──
@@ -164,7 +166,7 @@ class AgentOrchestrator:
164
166
  })
165
167
  rephrased_task = task
166
168
  try:
167
- rephrased_task = self.rephraser.rephrase(task, self.backend, task_context)
169
+ rephrased_task = self.rephraser.rephrase(task, task_backend, task_context)
168
170
  except Exception:
169
171
  rephrased_task = task
170
172
 
@@ -199,7 +201,7 @@ class AgentOrchestrator:
199
201
  yield AgentEvent("start", {
200
202
  "task": task,
201
203
  "workdir": str(session.workdir),
202
- "backend": self.backend.name,
204
+ "backend": task_backend.name,
203
205
  "is_offline": is_offline,
204
206
  "tool_count": len(active_tools),
205
207
  "complexity": complexity,
@@ -261,9 +263,9 @@ class AgentOrchestrator:
261
263
  else:
262
264
  max_tokens = 2048 if is_offline else effective_max_tokens
263
265
 
264
- if hasattr(self.backend, "chat_completion_stream") and not is_offline:
266
+ if hasattr(task_backend, "chat_completion_stream") and not is_offline:
265
267
  resp = None
266
- for event_type, data in self.backend.chat_completion_stream(
268
+ for event_type, data in task_backend.chat_completion_stream(
267
269
  messages=session.context.messages,
268
270
  tools=active_tools if active_tools else None,
269
271
  temperature=cfg.temperature,
@@ -275,7 +277,7 @@ class AgentOrchestrator:
275
277
  else:
276
278
  yield AgentEvent(event_type, data)
277
279
  else:
278
- resp = self.backend.chat_completion(
280
+ resp = task_backend.chat_completion(
279
281
  messages=session.context.messages,
280
282
  tools=active_tools if active_tools else None,
281
283
  temperature=cfg.temperature,
@@ -284,14 +286,14 @@ class AgentOrchestrator:
284
286
  )
285
287
  except Exception as e:
286
288
  # Online error -> fallback to local
287
- if not is_offline and isinstance(self.backend, OpenAIBackend):
289
+ if not is_offline and isinstance(task_backend, OpenAIBackend):
288
290
  yield AgentEvent("fallback", {"error": str(e), "message": "Falling back to local engine"})
289
- self.backend = LocalBackend(cfg)
291
+ task_backend = LocalBackend(cfg)
290
292
  session.is_offline = True
291
293
  is_offline = True
292
294
  active_tools = self.prompt_builder.get_tools(is_offline=True, complexity=complexity)
293
295
  try:
294
- resp = self.backend.chat_completion(
296
+ resp = task_backend.chat_completion(
295
297
  messages=session.context.messages,
296
298
  tools=active_tools if active_tools else None,
297
299
  temperature=cfg.temperature,
@@ -305,7 +307,11 @@ class AgentOrchestrator:
305
307
  yield AgentEvent("error", {"error": str(e)})
306
308
  return
307
309
 
308
- if resp.reasoning and not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
310
+ if resp is None:
311
+ yield AgentEvent("error", {"error": "Backend returned no response"})
312
+ return
313
+
314
+ if resp.reasoning and not (hasattr(task_backend, "chat_completion_stream") and not is_offline):
309
315
  yield AgentEvent("thinking", {"content": resp.reasoning})
310
316
 
311
317
  session.context.add_assistant_response(resp)
@@ -349,7 +355,7 @@ class AgentOrchestrator:
349
355
  resp.content = parts[0] + "DONE: " + parts[1].strip()
350
356
 
351
357
  # Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
352
- if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
358
+ if not (hasattr(task_backend, "chat_completion_stream") and not is_offline):
353
359
  yield AgentEvent("message", {"content": resp.content})
354
360
 
355
361
  extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
@@ -385,8 +391,8 @@ class AgentOrchestrator:
385
391
  has_successful_verify = any(
386
392
  c.get("exit_code", -1) == 0 for c in session_executed_commands
387
393
  )
388
- if has_code_files and not has_successful_verify and not getattr(self, "_verification_nudge", False):
389
- self._verification_nudge = True
394
+ if has_code_files and not has_successful_verify and not verification_nudged:
395
+ verification_nudged = True
390
396
  session.context.add_user_message(
391
397
  "[SYSTEM - VERIFICATION] You created/modified code files but did not run "
392
398
  "any verification command. Please run a quick syntax check or test before finishing. "
@@ -475,7 +481,7 @@ class AgentOrchestrator:
475
481
  resp.content = parts[0] + "DONE: " + parts[1].strip()
476
482
 
477
483
  # Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
478
- if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
484
+ if not (hasattr(task_backend, "chat_completion_stream") and not is_offline):
479
485
  yield AgentEvent("message", {"content": resp.content})
480
486
 
481
487
  extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
@@ -496,7 +496,7 @@ def _bash(workdir: Path, command: str) -> str:
496
496
 
497
497
  return output
498
498
  except subprocess.TimeoutExpired:
499
- return json.dumps({"stdout": "", "stderr": "Command timed out after 300s", "exit_code": -1})
499
+ return json.dumps({"stdout": "", "stderr": "Command timed out after 120s", "exit_code": -1})
500
500
 
501
501
 
502
502
  def _read_file(workdir: Path, path: str, start_line: int = None, end_line: int = None) -> str:
@@ -0,0 +1,205 @@
1
+ """
2
+ Unit tests verifying Phase 1 stability fixes:
3
+ - BUG-01: executor.py dead code removal
4
+ - BUG-02: cli.py duplicate command handler removal
5
+ - BUG-04: orchestrator.py verification nudge state leak prevention
6
+ - BUG-06: orchestrator.py backend immutability / thread safety
7
+ - BUG-09: tools.py bash timeout error message alignment
8
+ - BUG-11: compaction.py tool-call pairing integrity
9
+ """
10
+ import ast
11
+ from pathlib import Path
12
+ from unittest.mock import MagicMock, patch
13
+ import json
14
+ import pytest
15
+
16
+ from codeagent.executor import ToolExecutor, ToolCall, ToolResult
17
+ from codeagent.compaction import compact_messages, KEEP_RECENT_TURNS
18
+ from codeagent.orchestrator import AgentOrchestrator
19
+ from codeagent.session import SessionManager
20
+ from codeagent.config import Config
21
+ from codeagent.backends.base import ChatResponse
22
+ from codeagent.backends.openai_backend import OpenAIBackend
23
+ from codeagent.backends.local_backend import LocalBackend
24
+
25
+
26
+ def test_bug01_executor_dead_code_removed():
27
+ """Verify executor.py no longer contains the unreachable sequential execution block."""
28
+ executor_path = Path(__file__).resolve().parent.parent / "src" / "codeagent" / "executor.py"
29
+ content = executor_path.read_text(encoding="utf-8")
30
+ assert "# Sequential execution" not in content
31
+ assert content.count("return results") == 0
32
+
33
+
34
+ def test_bug01_executor_execution():
35
+ """Verify ToolExecutor correctly executes tool calls via the DAG pipeline."""
36
+ workdir = Path("/tmp")
37
+ executor = ToolExecutor(workdir)
38
+
39
+ def dummy_tool(args):
40
+ return f"result: {args.get('x', 0)}"
41
+
42
+ executor.dispatch["dummy_tool"] = dummy_tool
43
+
44
+ calls = [
45
+ ToolCall(id="c1", name="dummy_tool", arguments={"x": 42}),
46
+ ToolCall(id="c2", name="dummy_tool", arguments={"x": 99}),
47
+ ]
48
+
49
+ results = executor.execute_tool_calls(calls)
50
+ assert len(results) == 2
51
+ assert results[0].tool_call_id == "c1"
52
+ assert "result: 42" in results[0].content
53
+ assert results[1].tool_call_id == "c2"
54
+ assert "result: 99" in results[1].content
55
+
56
+
57
+ def test_bug02_cli_no_duplicate_handlers():
58
+ """Verify cli.py has exactly one diff and one doctor handler."""
59
+ cli_path = Path(__file__).resolve().parent.parent / "src" / "codeagent" / "cli.py"
60
+ content = cli_path.read_text(encoding="utf-8")
61
+
62
+ assert content.count('args.command == "diff"') == 1
63
+ assert content.count('args.command == "doctor"') == 1
64
+
65
+
66
+ def test_bug04_and_06_orchestrator_state_isolation(tmp_path):
67
+ """Verify orchestrator does not leak verification_nudge on self and does not mutate self.backend."""
68
+ mock_backend = MagicMock(spec=["name", "is_available", "chat_completion"])
69
+ mock_backend.name = "MockOpenAI"
70
+ mock_backend.is_available = True
71
+ mock_backend.chat_completion.return_value = ChatResponse(
72
+ content="DONE: Task is finished",
73
+ reasoning=None,
74
+ tool_calls=[],
75
+ )
76
+
77
+ orchestrator = AgentOrchestrator(backend=mock_backend)
78
+ session_mgr = SessionManager()
79
+ session = session_mgr.create_session(workdir=tmp_path, max_tokens=8192)
80
+ cfg = Config()
81
+
82
+ # Before run
83
+ assert not hasattr(orchestrator, "_verification_nudge")
84
+ original_backend = orchestrator.backend
85
+
86
+ events = list(orchestrator.run_task(session=session, task="Simple test task", cfg=cfg))
87
+
88
+ # After run: verify _verification_nudge is NOT set on orchestrator instance
89
+ assert not hasattr(orchestrator, "_verification_nudge")
90
+ # Verify backend on orchestrator was NOT mutated
91
+ assert orchestrator.backend is original_backend
92
+
93
+ done_events = [e for e in events if e.type == "done"]
94
+ assert len(done_events) == 1
95
+
96
+
97
+ def test_bug06_fallback_preserves_orchestrator_backend(tmp_path):
98
+ """Verify that when online backend fails and falls back to local, self.backend is NOT mutated."""
99
+ mock_openai = MagicMock(spec=OpenAIBackend)
100
+ mock_openai.name = "OpenAI"
101
+ mock_openai.is_available = True
102
+ mock_openai.chat_completion.side_effect = ConnectionError("Online service unreachable")
103
+ # Disallow stream so it hits chat_completion directly
104
+ del mock_openai.chat_completion_stream
105
+
106
+ orchestrator = AgentOrchestrator(backend=mock_openai)
107
+ session_mgr = SessionManager()
108
+ session = session_mgr.create_session(workdir=tmp_path, max_tokens=8192)
109
+ cfg = Config()
110
+
111
+ fallback_resp = ChatResponse(
112
+ content="DONE: Handled by local fallback",
113
+ reasoning=None,
114
+ tool_calls=[],
115
+ )
116
+
117
+ with patch.object(LocalBackend, "__init__", return_value=None), \
118
+ patch.object(LocalBackend, "chat_completion", return_value=fallback_resp):
119
+ events = list(orchestrator.run_task(session=session, task="Fallback test", cfg=cfg))
120
+
121
+ event_types = [e.type for e in events]
122
+ assert "fallback" in event_types
123
+ assert "done" in event_types
124
+
125
+ # Critical check: orchestrator.backend must still be the original OpenAI backend!
126
+ assert orchestrator.backend is mock_openai
127
+
128
+
129
+ def test_orchestrator_streaming(tmp_path):
130
+ """Verify orchestrator handles streaming backends properly."""
131
+ mock_backend = MagicMock(spec=["name", "is_available", "chat_completion", "chat_completion_stream"])
132
+ mock_backend.name = "MockStreamBackend"
133
+ mock_backend.is_available = True
134
+
135
+ chat_resp = ChatResponse(
136
+ content="DONE: Stream completed",
137
+ reasoning="thinking about stream",
138
+ tool_calls=[],
139
+ )
140
+ mock_backend.chat_completion_stream.return_value = iter([
141
+ ("token", {"delta": "DONE: "}),
142
+ ("token", {"delta": "Stream completed"}),
143
+ ("done", chat_resp),
144
+ ])
145
+
146
+ orchestrator = AgentOrchestrator(backend=mock_backend)
147
+ session_mgr = SessionManager()
148
+ session = session_mgr.create_session(workdir=tmp_path, max_tokens=8192)
149
+ cfg = Config()
150
+
151
+ events = list(orchestrator.run_task(session=session, task="Streaming test", cfg=cfg))
152
+ event_types = [e.type for e in events]
153
+ assert "token" in event_types
154
+ assert "done" in event_types
155
+
156
+
157
+ def test_bug09_bash_timeout_message(tmp_path):
158
+ """Verify tools.py _bash returns 120s timeout in error message."""
159
+ import subprocess
160
+ from codeagent.tools import _bash
161
+
162
+ with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="sleep 200", timeout=120)):
163
+ res = _bash(tmp_path, "sleep 200")
164
+ data = json.loads(res)
165
+ assert data["exit_code"] == -1
166
+ assert "120s" in data["stderr"]
167
+ assert "300s" not in data["stderr"]
168
+
169
+
170
+ def test_bug11_compaction_tool_pairing_guard():
171
+ """Verify compact_messages does not split an assistant tool-call from its tool results."""
172
+ # Build a message list where KEEP_RECENT_TURNS would land right on a tool message
173
+ messages = [
174
+ {"role": "system", "content": "You are AlpieCode."},
175
+ {"role": "user", "content": "Initial prompt"},
176
+ {"role": "assistant", "content": "Thinking about step 1..."},
177
+ {"role": "user", "content": "Continue"},
178
+ ]
179
+
180
+ # Add several turns of assistant + tool calls
181
+ for i in range(10):
182
+ call_id = f"call_{i}"
183
+ messages.append({
184
+ "role": "assistant",
185
+ "content": f"Running tool {i}",
186
+ "tool_calls": [{"id": call_id, "function": {"name": "read_file", "arguments": "{}"}}]
187
+ })
188
+ messages.append({
189
+ "role": "tool",
190
+ "tool_call_id": call_id,
191
+ "content": f"file content {i}"
192
+ })
193
+
194
+ compacted = compact_messages(messages)
195
+ assert len(compacted) > 0
196
+ assert compacted[0]["role"] == "system"
197
+
198
+ # In compacted messages, every 'tool' message must be immediately preceded by
199
+ # an 'assistant' message that contains its tool_call_id or another 'tool' from the same assistant
200
+ for idx, msg in enumerate(compacted):
201
+ if msg.get("role") == "tool":
202
+ prev_msg = compacted[idx - 1]
203
+ assert prev_msg.get("role") in ("assistant", "tool"), (
204
+ f"Tool message at index {idx} was preceded by role '{prev_msg.get('role')}'"
205
+ )
File without changes
File without changes