autopilot-code 1.0.0 → 2.1.0

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.
@@ -0,0 +1,247 @@
1
+ """
2
+ OpenCode server-based agent implementation.
3
+
4
+ This agent uses the OpenCode HTTP server API instead of the CLI,
5
+ providing proper session management and continuity across multiple
6
+ agent calls within the same issue workflow.
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from .base import BaseAgent, AgentResult
16
+ from .opencode_client import OpenCodeClient, OpenCodeServerManager
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Global server manager shared across agent instances.
21
+ # This is intentionally global (not a class variable or injected) because:
22
+ # 1. Multiple IssueRunner instances may create separate agent instances
23
+ # 2. The manager must track ALL running servers to properly reuse/cleanup
24
+ # 3. Python's GIL makes this safe for our single-threaded runner
25
+ _server_manager: Optional[OpenCodeServerManager] = None
26
+
27
+
28
+ def get_server_manager(binary_path: str = "opencode") -> OpenCodeServerManager:
29
+ """Get or create the global server manager."""
30
+ global _server_manager
31
+ if _server_manager is None:
32
+ _server_manager = OpenCodeServerManager(binary_path)
33
+ return _server_manager
34
+
35
+
36
+ def reset_server_manager() -> None:
37
+ """Reset the global server manager. Used for testing."""
38
+ global _server_manager
39
+ if _server_manager is not None:
40
+ _server_manager.stop_all()
41
+ _server_manager = None
42
+
43
+
44
+ class OpenCodeServerAgent(BaseAgent):
45
+ """
46
+ Agent implementation using OpenCode HTTP server.
47
+
48
+ Unlike the CLI-based agent, this implementation:
49
+ - Starts an OpenCode server per worktree
50
+ - Maintains proper session continuity via session IDs
51
+ - Persists sessions across server restarts (sessions stored in worktree)
52
+ """
53
+
54
+ def __init__(self, config: dict):
55
+ super().__init__(config)
56
+ self._server_manager: Optional[OpenCodeServerManager] = None
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return "OpenCode Server"
61
+
62
+ @property
63
+ def supports_sessions(self) -> bool:
64
+ return True
65
+
66
+ @property
67
+ def server_manager(self) -> OpenCodeServerManager:
68
+ """Get the server manager, initializing if needed.
69
+
70
+ Note: self.binary_path is inherited from BaseAgent which caches
71
+ the result of find_binary() on first access.
72
+ """
73
+ if self._server_manager is None:
74
+ self._server_manager = get_server_manager(self.binary_path)
75
+ return self._server_manager
76
+
77
+ def find_binary(self) -> str:
78
+ """
79
+ Locate the opencode binary.
80
+
81
+ Search order:
82
+ 1. agentPath from config
83
+ 2. PATH
84
+ 3. Common nvm locations
85
+ 4. Other common locations
86
+ """
87
+ # 1. Config-specified path
88
+ agent_path = self.config.get("agentPath", "")
89
+ if agent_path and os.path.isfile(agent_path) and os.access(agent_path, os.X_OK):
90
+ return agent_path
91
+
92
+ # 2. Already in PATH
93
+ which_result = shutil.which("opencode")
94
+ if which_result:
95
+ return which_result
96
+
97
+ # 3. Common nvm locations
98
+ home = Path.home()
99
+ nvm_dir = home / ".nvm" / "versions" / "node"
100
+ if nvm_dir.exists():
101
+ for node_dir in nvm_dir.iterdir():
102
+ opencode_path = node_dir / "bin" / "opencode"
103
+ if opencode_path.exists() and os.access(opencode_path, os.X_OK):
104
+ return str(opencode_path)
105
+
106
+ # 4. Other common locations
107
+ common_paths = [
108
+ home / ".local" / "bin" / "opencode",
109
+ Path("/usr/local/bin/opencode"),
110
+ home / ".npm-global" / "bin" / "opencode",
111
+ ]
112
+ for path in common_paths:
113
+ if path.exists() and os.access(path, os.X_OK):
114
+ return str(path)
115
+
116
+ raise FileNotFoundError(
117
+ "opencode not found. Set 'agentPath' in autopilot.json or ensure opencode is installed."
118
+ )
119
+
120
+ def _get_client(self, worktree: Path) -> OpenCodeClient:
121
+ """
122
+ Get an OpenCode client for a worktree, starting server if needed.
123
+
124
+ Args:
125
+ worktree: Path to the worktree
126
+
127
+ Returns:
128
+ OpenCodeClient connected to server
129
+
130
+ Raises:
131
+ RuntimeError: If server couldn't be started
132
+ """
133
+ client = self.server_manager.get_client(worktree)
134
+ if client is None:
135
+ raise RuntimeError(f"Failed to start OpenCode server for {worktree}")
136
+ return client
137
+
138
+ def _ensure_session(
139
+ self,
140
+ client: OpenCodeClient,
141
+ session_id: Optional[str],
142
+ title: Optional[str] = None,
143
+ ) -> str:
144
+ """
145
+ Ensure a valid session exists, creating one if needed.
146
+
147
+ Args:
148
+ client: OpenCode client
149
+ session_id: Existing session ID (if any)
150
+ title: Title for new session
151
+
152
+ Returns:
153
+ Valid session ID
154
+
155
+ Raises:
156
+ RuntimeError: If session couldn't be created
157
+ """
158
+ # Check if existing session is valid
159
+ if session_id and client.session_exists(session_id):
160
+ logger.info(f"Reusing existing session: {session_id}")
161
+ return session_id
162
+
163
+ # Create new session
164
+ new_session_id = client.create_session(title=title)
165
+ if not new_session_id:
166
+ raise RuntimeError("Failed to create OpenCode session")
167
+
168
+ logger.info(f"Created new session: {new_session_id}")
169
+ return new_session_id
170
+
171
+ def run(
172
+ self,
173
+ worktree: Path,
174
+ prompt: str,
175
+ session_id: Optional[str] = None,
176
+ ) -> AgentResult:
177
+ """
178
+ Run OpenCode with the given prompt.
179
+
180
+ Args:
181
+ worktree: Working directory for the agent
182
+ prompt: The task/prompt
183
+ session_id: Previous session ID to continue (if any)
184
+
185
+ Returns:
186
+ AgentResult with session_id for future continuation
187
+ """
188
+ try:
189
+ # Get client (starts server if needed)
190
+ client = self._get_client(worktree)
191
+
192
+ # Ensure we have a valid session
193
+ session_id = self._ensure_session(client, session_id, title="autopilot")
194
+
195
+ # Send message
196
+ logger.info(f"Sending message to session {session_id}")
197
+ response = client.send_message(session_id, prompt)
198
+
199
+ if response is None:
200
+ return AgentResult(
201
+ success=False,
202
+ session_id=session_id,
203
+ output="",
204
+ error="No response from OpenCode server",
205
+ )
206
+
207
+ # Extract text from response
208
+ output_text = response.get_text()
209
+
210
+ # Check finish reason
211
+ success = response.finish_reason in ("stop", "tool-calls", None)
212
+
213
+ return AgentResult(
214
+ success=success,
215
+ session_id=session_id,
216
+ output=output_text,
217
+ error=None if success else f"Unexpected finish: {response.finish_reason}",
218
+ )
219
+
220
+ except Exception as e:
221
+ logger.exception("Error running OpenCode server agent")
222
+ return AgentResult(
223
+ success=False,
224
+ session_id=session_id,
225
+ output="",
226
+ error=str(e),
227
+ )
228
+
229
+ def stop_server(self, worktree: Path) -> None:
230
+ """
231
+ Stop the server for a worktree.
232
+
233
+ Should be called during cleanup to release resources.
234
+
235
+ Args:
236
+ worktree: Path to the worktree
237
+ """
238
+ self.server_manager.stop_server(worktree)
239
+
240
+ def cleanup(self, worktree: Path) -> None:
241
+ """
242
+ Clean up resources for a worktree.
243
+
244
+ Args:
245
+ worktree: Path to the worktree
246
+ """
247
+ self.stop_server(worktree)
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Integration tests for OpenCode server agent.
4
+
5
+ Run with: python3 -m scripts.issue_runner.agents.test_opencode_server
6
+
7
+ These tests require OpenCode to be installed and configured.
8
+
9
+ NOTE: Some tests (session_continuity, session_persistence, concurrent_sessions)
10
+ rely on LLM responses and may occasionally fail due to non-deterministic model
11
+ output. This is expected - the tests verify real end-to-end behavior rather
12
+ than mocking. Re-run if a single test fails sporadically.
13
+ """
14
+
15
+ import logging
16
+ import sys
17
+ import tempfile
18
+ import shutil
19
+ from pathlib import Path
20
+
21
+ # Setup logging
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class TestResult:
30
+ def __init__(self, name: str):
31
+ self.name = name
32
+ self.passed = False
33
+ self.error = None
34
+
35
+ def __str__(self):
36
+ status = "✅ PASS" if self.passed else f"❌ FAIL: {self.error}"
37
+ return f"{self.name}: {status}"
38
+
39
+
40
+ def test_imports() -> TestResult:
41
+ """Test that all imports work correctly."""
42
+ result = TestResult("imports")
43
+ try:
44
+ from scripts.issue_runner.agents import get_agent, OpenCodeServerAgent
45
+ from scripts.issue_runner.agents.opencode_client import (
46
+ OpenCodeClient,
47
+ OpenCodeServerManager,
48
+ ServerInfo,
49
+ MessageResponse,
50
+ )
51
+ result.passed = True
52
+ except Exception as e:
53
+ result.error = str(e)
54
+ return result
55
+
56
+
57
+ def test_agent_factory() -> TestResult:
58
+ """Test that the agent factory creates the correct agent type."""
59
+ result = TestResult("agent_factory")
60
+ try:
61
+ from scripts.issue_runner.agents import get_agent, OpenCodeServerAgent
62
+
63
+ agent = get_agent("opencode-server", {})
64
+ assert isinstance(agent, OpenCodeServerAgent), f"Wrong type: {type(agent)}"
65
+ assert agent.name == "OpenCode Server"
66
+ assert agent.supports_sessions is True
67
+ result.passed = True
68
+ except Exception as e:
69
+ result.error = str(e)
70
+ return result
71
+
72
+
73
+ def test_find_binary() -> TestResult:
74
+ """Test that the opencode binary can be found."""
75
+ result = TestResult("find_binary")
76
+ try:
77
+ from scripts.issue_runner.agents import get_agent
78
+
79
+ agent = get_agent("opencode-server", {})
80
+ binary = agent.find_binary()
81
+ assert binary is not None, "Binary not found"
82
+ assert Path(binary).exists(), f"Binary does not exist: {binary}"
83
+ result.passed = True
84
+ except FileNotFoundError as e:
85
+ result.error = f"OpenCode not installed: {e}"
86
+ except Exception as e:
87
+ result.error = str(e)
88
+ return result
89
+
90
+
91
+ def test_server_start_stop(worktree: Path) -> TestResult:
92
+ """Test starting and stopping a server."""
93
+ result = TestResult("server_start_stop")
94
+ try:
95
+ from scripts.issue_runner.agents.opencode_client import OpenCodeServerManager
96
+
97
+ manager = OpenCodeServerManager()
98
+
99
+ # Start server
100
+ info = manager.start_server(worktree)
101
+ assert info is not None, "Failed to start server"
102
+ assert info.port > 0, f"Invalid port: {info.port}"
103
+ assert info.pid > 0, f"Invalid PID: {info.pid}"
104
+
105
+ # Stop server
106
+ stopped = manager.stop_server(worktree)
107
+ assert stopped, "Failed to stop server"
108
+
109
+ result.passed = True
110
+ except Exception as e:
111
+ result.error = str(e)
112
+ return result
113
+
114
+
115
+ def test_session_creation(worktree: Path) -> TestResult:
116
+ """Test creating a session."""
117
+ result = TestResult("session_creation")
118
+ try:
119
+ from scripts.issue_runner.agents import get_agent
120
+
121
+ agent = get_agent("opencode-server", {})
122
+
123
+ # Run a simple command to create session
124
+ res = agent.run(worktree, "What is 1+1? Reply with just the number.")
125
+
126
+ assert res.success, f"Agent failed: {res.error}"
127
+ assert res.session_id is not None, "No session ID returned"
128
+ # OpenCode uses "ses_" prefix as of v1.x - if this fails, check version compatibility
129
+ assert res.session_id.startswith("ses_"), f"Invalid session ID: {res.session_id}"
130
+
131
+ agent.cleanup(worktree)
132
+ result.passed = True
133
+ except Exception as e:
134
+ result.error = str(e)
135
+ return result
136
+
137
+
138
+ def test_session_continuity(worktree: Path) -> TestResult:
139
+ """Test that session continuity works within a single server."""
140
+ result = TestResult("session_continuity")
141
+ try:
142
+ from scripts.issue_runner.agents import get_agent
143
+
144
+ agent = get_agent("opencode-server", {})
145
+
146
+ # First message - set a secret
147
+ res1 = agent.run(worktree, "Remember: the password is ELEPHANT. Just say OK.")
148
+ assert res1.success, f"First message failed: {res1.error}"
149
+ session_id = res1.session_id
150
+
151
+ # Second message - recall the secret
152
+ res2 = agent.run(worktree, "What was the password?", session_id=session_id)
153
+ assert res2.success, f"Second message failed: {res2.error}"
154
+ assert res2.session_id == session_id, "Session ID changed"
155
+ assert "ELEPHANT" in res2.output.upper(), f"Password not recalled: {res2.output}"
156
+
157
+ agent.cleanup(worktree)
158
+ result.passed = True
159
+ except Exception as e:
160
+ result.error = str(e)
161
+ return result
162
+
163
+
164
+ def test_session_persistence(worktree: Path) -> TestResult:
165
+ """Test that sessions persist across server restarts."""
166
+ result = TestResult("session_persistence")
167
+ try:
168
+ from scripts.issue_runner.agents import get_agent
169
+
170
+ # First agent - set secret and stop
171
+ agent1 = get_agent("opencode-server", {})
172
+ res1 = agent1.run(worktree, "Remember: the code is ZEBRA. Just say OK.")
173
+ assert res1.success, f"First message failed: {res1.error}"
174
+ session_id = res1.session_id
175
+
176
+ # Stop the server
177
+ agent1.cleanup(worktree)
178
+
179
+ # Second agent - recall secret with same session ID
180
+ agent2 = get_agent("opencode-server", {})
181
+ res2 = agent2.run(worktree, "What was the code?", session_id=session_id)
182
+ assert res2.success, f"Second message failed: {res2.error}"
183
+ assert res2.session_id == session_id, "Session ID changed after restart"
184
+ assert "ZEBRA" in res2.output.upper(), f"Code not recalled after restart: {res2.output}"
185
+
186
+ agent2.cleanup(worktree)
187
+ result.passed = True
188
+ except Exception as e:
189
+ result.error = str(e)
190
+ return result
191
+
192
+
193
+ def test_concurrent_sessions(worktree1: Path, worktree2: Path) -> TestResult:
194
+ """Test that multiple worktrees have independent sessions.
195
+
196
+ "Concurrent" here means multiple worktrees can have active sessions
197
+ simultaneously (each with its own server), not parallel thread execution.
198
+ This verifies session isolation - each worktree remembers its own context.
199
+ """
200
+ result = TestResult("concurrent_sessions")
201
+ try:
202
+ from scripts.issue_runner.agents import get_agent
203
+
204
+ agent = get_agent("opencode-server", {})
205
+
206
+ # Create sessions in both worktrees (runs sequentially but sessions coexist)
207
+ res1 = agent.run(worktree1, "Remember: worktree1 secret is APPLE. Say OK.")
208
+ res2 = agent.run(worktree2, "Remember: worktree2 secret is BANANA. Say OK.")
209
+
210
+ assert res1.success, f"Worktree1 failed: {res1.error}"
211
+ assert res2.success, f"Worktree2 failed: {res2.error}"
212
+ assert res1.session_id != res2.session_id, "Sessions should be different"
213
+
214
+ # Verify each remembers its own secret
215
+ res1b = agent.run(worktree1, "What was the secret?", session_id=res1.session_id)
216
+ res2b = agent.run(worktree2, "What was the secret?", session_id=res2.session_id)
217
+
218
+ assert "APPLE" in res1b.output.upper(), f"Worktree1 wrong: {res1b.output}"
219
+ assert "BANANA" in res2b.output.upper(), f"Worktree2 wrong: {res2b.output}"
220
+
221
+ agent.cleanup(worktree1)
222
+ agent.cleanup(worktree2)
223
+ result.passed = True
224
+ except Exception as e:
225
+ result.error = str(e)
226
+ return result
227
+
228
+
229
+ def run_tests():
230
+ """Run all tests and report results."""
231
+ from scripts.issue_runner.agents.opencode_server import reset_server_manager
232
+
233
+ print("\n" + "=" * 60)
234
+ print("OpenCode Server Agent - Integration Tests")
235
+ print("=" * 60 + "\n")
236
+
237
+ results = []
238
+
239
+ # Basic tests that don't need a worktree
240
+ print("Running basic tests...")
241
+ results.append(test_imports())
242
+ results.append(test_agent_factory())
243
+ results.append(test_find_binary())
244
+
245
+ # Check if we can proceed with integration tests
246
+ if not all(r.passed for r in results):
247
+ print("\n⚠️ Basic tests failed, skipping integration tests\n")
248
+ else:
249
+ # Create temporary worktrees for integration tests
250
+ print("\nRunning integration tests (requires OpenCode)...")
251
+
252
+ # Reset global state before integration tests
253
+ reset_server_manager()
254
+
255
+ # Use the current repo as worktree for single-worktree tests
256
+ worktree = Path(__file__).parent.parent.parent.parent.resolve()
257
+
258
+ results.append(test_server_start_stop(worktree))
259
+ reset_server_manager() # Clean state between tests
260
+
261
+ results.append(test_session_creation(worktree))
262
+ reset_server_manager()
263
+
264
+ results.append(test_session_continuity(worktree))
265
+ reset_server_manager()
266
+
267
+ results.append(test_session_persistence(worktree))
268
+ reset_server_manager()
269
+
270
+ # For concurrent test, we need two different directories
271
+ # Use temp directories that are git repos
272
+ temp1 = None
273
+ temp2 = None
274
+ try:
275
+ temp1 = Path(tempfile.mkdtemp(prefix="autopilot-test1-"))
276
+ temp2 = Path(tempfile.mkdtemp(prefix="autopilot-test2-"))
277
+
278
+ # Initialize as git repos (required by opencode)
279
+ import subprocess
280
+ subprocess.run(["git", "init"], cwd=temp1, capture_output=True)
281
+ subprocess.run(["git", "init"], cwd=temp2, capture_output=True)
282
+
283
+ results.append(test_concurrent_sessions(temp1, temp2))
284
+ except Exception as e:
285
+ result = TestResult("concurrent_sessions")
286
+ result.error = f"Setup failed: {e}"
287
+ results.append(result)
288
+ finally:
289
+ # Always cleanup temp dirs and reset server state
290
+ reset_server_manager()
291
+ if temp1:
292
+ shutil.rmtree(temp1, ignore_errors=True)
293
+ if temp2:
294
+ shutil.rmtree(temp2, ignore_errors=True)
295
+
296
+ # Print results
297
+ print("\n" + "-" * 60)
298
+ print("Results:")
299
+ print("-" * 60)
300
+
301
+ for r in results:
302
+ print(f" {r}")
303
+
304
+ passed = sum(1 for r in results if r.passed)
305
+ total = len(results)
306
+
307
+ print("-" * 60)
308
+ print(f"\n{'✅' if passed == total else '❌'} {passed}/{total} tests passed\n")
309
+
310
+ return passed == total
311
+
312
+
313
+ if __name__ == "__main__":
314
+ # Change to repo root so imports work
315
+ import os
316
+ repo_root = Path(__file__).parent.parent.parent.parent.resolve()
317
+ os.chdir(repo_root)
318
+ sys.path.insert(0, str(repo_root))
319
+
320
+ success = run_tests()
321
+ sys.exit(0 if success else 1)
@@ -81,6 +81,7 @@ class IssueRunner:
81
81
  updated_at=datetime.utcnow().isoformat() + "Z",
82
82
  )
83
83
  self._save_state(state, f"❌ Failed at step {state.step.value}: {e}")
84
+ self._cleanup_agent(Path(state.worktree))
84
85
  return False
85
86
 
86
87
  return state.step == IssueStep.DONE
@@ -110,6 +111,14 @@ class IssueRunner:
110
111
  message = STEP_STATUS_MESSAGES.get(state.step, f"Step: {state.step.value}")
111
112
  self.github.save_state(state.issue_number, state, message)
112
113
 
114
+ def _cleanup_agent(self, worktree: Path) -> None:
115
+ """Clean up agent resources if supported."""
116
+ if hasattr(self.agent, "cleanup"):
117
+ try:
118
+ self.agent.cleanup(worktree)
119
+ except Exception as e:
120
+ logger.warning(f"Error cleaning up agent: {e}")
121
+
113
122
  def _transition(self, state: StateData) -> StateData:
114
123
  """Execute one state transition."""
115
124
  handlers = {
@@ -672,6 +681,8 @@ This PR is automatically created by Autopilot to implement issue #{state.issue_n
672
681
 
673
682
  # Cleanup worktree
674
683
  worktree = Path(state.worktree)
684
+ self._cleanup_agent(worktree)
685
+
675
686
  if self.git.worktree_exists(worktree):
676
687
  self.git.remove_worktree(worktree)
677
688