opencode-agent-hub 1.3.3__tar.gz → 1.4.1__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 (35) hide show
  1. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/PKG-INFO +1 -1
  2. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/pyproject.toml +1 -1
  3. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/__init__.py +1 -1
  4. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/daemon.py +102 -18
  5. opencode_agent_hub-1.4.1/tests/test_agent_detection.py +426 -0
  6. opencode_agent_hub-1.4.1/tests/test_integration.py +338 -0
  7. opencode_agent_hub-1.4.1/tests/test_message_injection.py +285 -0
  8. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_orientation_retry.py +3 -3
  9. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/.gitignore +0 -0
  10. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/LICENSE +0 -0
  11. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/README.md +0 -0
  12. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/coordinator/AGENTS.md +0 -0
  13. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/coordinator/opencode.json +0 -0
  14. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/launchd/com.xnoto.agent-hub-daemon.plist +0 -0
  15. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/aur/PKGBUILD +0 -0
  16. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/changelog +0 -0
  17. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/control +0 -0
  18. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/copyright +0 -0
  19. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/docs +0 -0
  20. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/install +0 -0
  21. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/postinst +0 -0
  22. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/rules +0 -0
  23. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/source/format +0 -0
  24. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/rpm/opencode-agent-hub.spec +0 -0
  25. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/systemd/agent-hub-daemon.service +0 -0
  26. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/py.typed +0 -0
  27. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/watch.py +0 -0
  28. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/__init__.py +0 -0
  29. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_config.py +0 -0
  30. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_coordinator.py +0 -0
  31. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_coordinator_cost.py +0 -0
  32. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_placeholder.py +0 -0
  33. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_rate_limiting.py +0 -0
  34. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_session_agents.py +0 -0
  35. {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-agent-hub
3
- Version: 1.3.3
3
+ Version: 1.4.1
4
4
  Summary: Multi-agent coordination daemon and tools for OpenCode
5
5
  Project-URL: Homepage, https://github.com/xnoto/opencode-agent-hub
6
6
  Project-URL: Repository, https://github.com/xnoto/opencode-agent-hub
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opencode-agent-hub"
7
- version = "1.3.3"
7
+ version = "1.4.1"
8
8
  description = "Multi-agent coordination daemon and tools for OpenCode"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,4 +8,4 @@ from importlib.metadata import PackageNotFoundError, version
8
8
  try:
9
9
  __version__ = version("opencode-agent-hub")
10
10
  except PackageNotFoundError: # pragma: no cover - fallback for dev
11
- __version__ = "1.3.3"
11
+ __version__ = "1.4.1"
@@ -532,6 +532,7 @@ _sessions_cache_lock = threading.Lock()
532
532
  class InjectionTask:
533
533
  session_id: str
534
534
  text: str
535
+ agent: str | None = None
535
536
 
536
537
 
537
538
  @dataclass
@@ -1867,6 +1868,13 @@ def start_coordinator() -> bool:
1867
1868
  COORDINATOR_SESSION_ID = session_id
1868
1869
  ORIENTED_SESSIONS.add(session_id)
1869
1870
 
1871
+ # Log the coordinator's agent if we can detect it
1872
+ coordinator_agent_name = get_agent_from_session_messages(session_id)
1873
+ if coordinator_agent_name:
1874
+ log.info(f"Coordinator is using agent: {coordinator_agent_name}")
1875
+ else:
1876
+ log.info("Coordinator agent not yet detected (will be logged after first message)")
1877
+
1870
1878
  # Register coordinator as an agent so other agents can message it
1871
1879
  coordinator_agent = {
1872
1880
  "id": "coordinator",
@@ -1972,14 +1980,15 @@ def notify_coordinator_new_agent(agent_id: str, directory: str) -> None:
1972
1980
 
1973
1981
  before_ms = int(time.time() * 1000)
1974
1982
  notification = f"NEW_AGENT: {agent_id} at {directory}"
1975
- inject_message(COORDINATOR_SESSION_ID, notification)
1983
+ # Notification to coordinator should use coordinator's own agent
1984
+ inject_message(COORDINATOR_SESSION_ID, notification, agent="coordinator")
1976
1985
  log.info(f"Notified coordinator of new agent: {agent_id}")
1977
1986
 
1978
1987
  if not _wait_for_coordinator_activity(COORDINATOR_SESSION_ID, before_ms, timeout_seconds=5):
1979
1988
  log.warning(
1980
1989
  f"Coordinator showed no activity after NEW_AGENT {agent_id}; retrying notification once"
1981
1990
  )
1982
- inject_message(COORDINATOR_SESSION_ID, notification)
1991
+ inject_message(COORDINATOR_SESSION_ID, notification, agent="coordinator")
1983
1992
 
1984
1993
 
1985
1994
  def poll_coordinator_cost() -> None:
@@ -2012,6 +2021,14 @@ def poll_coordinator_cost() -> None:
2012
2021
  if not isinstance(messages, list):
2013
2022
  return
2014
2023
 
2024
+ # Log the coordinator's current agent from most recent message
2025
+ if messages:
2026
+ most_recent = messages[-1] # Messages are in chronological order
2027
+ info = most_recent.get("info", {})
2028
+ agent = info.get("agent")
2029
+ if agent:
2030
+ log.debug(f"Coordinator using agent: {agent}")
2031
+
2015
2032
  # Sum token usage from all assistant messages
2016
2033
  total_input = 0
2017
2034
  total_output = 0
@@ -2180,16 +2197,24 @@ def find_sessions_for_agent(agent: dict, sessions: list[dict]) -> list[dict]:
2180
2197
  return [matching[0]]
2181
2198
 
2182
2199
 
2183
- def inject_message_sync(session_id: str, text: str) -> bool:
2200
+ def inject_message_sync(session_id: str, text: str, agent: str | None = None) -> bool:
2184
2201
  """Inject message into OpenCode session (synchronous, with retries).
2185
2202
 
2186
2203
  Uses /prompt_async endpoint which triggers LLM invocation even when idle.
2187
2204
  The /message endpoint with noReply:false only adds to context without
2188
2205
  actually invoking the LLM when the session is idle.
2206
+
2207
+ Args:
2208
+ session_id: Target session ID
2209
+ text: Message text to inject
2210
+ agent: Optional agent ID to use for handling this message. When provided,
2211
+ OpenCode will use this agent instead of the default.
2189
2212
  """
2190
- payload = {
2213
+ payload: dict[str, Any] = {
2191
2214
  "parts": [{"type": "text", "text": text}],
2192
2215
  }
2216
+ if agent:
2217
+ payload["agent"] = agent
2193
2218
 
2194
2219
  for attempt in range(INJECTION_RETRIES):
2195
2220
  try:
@@ -2202,7 +2227,10 @@ def inject_message_sync(session_id: str, text: str) -> bool:
2202
2227
  )
2203
2228
  # prompt_async returns 204 No Content on success
2204
2229
  if resp.status_code in (200, 204):
2205
- log.info(f"Injected message into session {session_id[:8]}... (prompt_async)")
2230
+ agent_info = f" [agent={agent}]" if agent else ""
2231
+ log.info(
2232
+ f"Injected message into session {session_id[:8]}... (prompt_async){agent_info}"
2233
+ )
2206
2234
  metrics.inc("agent_hub_injections_total")
2207
2235
  return True
2208
2236
  else:
@@ -2219,9 +2247,15 @@ def inject_message_sync(session_id: str, text: str) -> bool:
2219
2247
  return False
2220
2248
 
2221
2249
 
2222
- def inject_message(session_id: str, text: str) -> None:
2223
- """Queue message for async injection (non-blocking)."""
2224
- _injection_queue.put(InjectionTask(session_id=session_id, text=text))
2250
+ def inject_message(session_id: str, text: str, agent: str | None = None) -> None:
2251
+ """Queue message for async injection (non-blocking).
2252
+
2253
+ Args:
2254
+ session_id: Target session ID
2255
+ text: Message text to inject
2256
+ agent: Optional agent ID to use for handling this message
2257
+ """
2258
+ _injection_queue.put(InjectionTask(session_id=session_id, text=text, agent=agent))
2225
2259
 
2226
2260
 
2227
2261
  def injection_worker(shutdown_event: threading.Event) -> None:
@@ -2233,7 +2267,7 @@ def injection_worker(shutdown_event: threading.Event) -> None:
2233
2267
  continue
2234
2268
 
2235
2269
  try:
2236
- inject_message_sync(task.session_id, task.text)
2270
+ inject_message_sync(task.session_id, task.text, task.agent)
2237
2271
  except Exception as e:
2238
2272
  log.error(f"Injection worker error: {e}")
2239
2273
  finally:
@@ -2371,6 +2405,47 @@ def generate_agent_id_for_session(session: dict[str, Any]) -> str:
2371
2405
  return f"session-{session_id[:12]}" if session_id else "unknown-session"
2372
2406
 
2373
2407
 
2408
+ def get_agent_from_session_messages(session_id: str) -> str | None:
2409
+ """Get the current agent for a session from the most recent message.
2410
+
2411
+ Queries the OpenCode SQLite database to find the agent used in the
2412
+ most recent message for this session. This reflects the actual running
2413
+ agent, not just the configured default.
2414
+
2415
+ Args:
2416
+ session_id: The session ID to look up
2417
+
2418
+ Returns:
2419
+ The agent name (e.g., "kimi", "claude", " coordinator") or None
2420
+ """
2421
+ if not session_id or not OPENCODE_DB_PATH.exists():
2422
+ return None
2423
+
2424
+ try:
2425
+ conn = sqlite3.connect(f"file:{OPENCODE_DB_PATH}?mode=ro", uri=True, timeout=5)
2426
+ conn.row_factory = sqlite3.Row
2427
+ try:
2428
+ # Get the most recent message for this session
2429
+ row = conn.execute(
2430
+ "SELECT data FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 1",
2431
+ (session_id,),
2432
+ ).fetchone()
2433
+
2434
+ if row and row["data"]:
2435
+ msg_data = json.loads(row["data"])
2436
+ # Agent is stored in the message data
2437
+ agent = msg_data.get("agent")
2438
+ if isinstance(agent, str) and agent:
2439
+ log.debug(f"Detected agent '{agent}' from session {session_id[:12]}")
2440
+ return agent
2441
+ finally:
2442
+ conn.close()
2443
+ except (sqlite3.Error, json.JSONDecodeError, OSError) as e:
2444
+ log.debug(f"Failed to get agent from session messages: {e}")
2445
+
2446
+ return None
2447
+
2448
+
2374
2449
  def get_or_create_agent_for_session(
2375
2450
  session: dict[str, Any], agents: dict[str, dict[str, Any]]
2376
2451
  ) -> dict[str, Any]:
@@ -2380,11 +2455,10 @@ def get_or_create_agent_for_session(
2380
2455
  identity per session, allowing multiple TUI sessions in the same directory
2381
2456
  to have separate agent identities.
2382
2457
 
2383
- The agent ID is derived from the session's slug (if available) or session ID,
2384
- ensuring uniqueness across all sessions.
2458
+ The agent ID is derived from the session's actual running agent (queried
2459
+ from the SQLite database), or falls back to the session slug/ID.
2385
2460
  """
2386
2461
  session_id = cast(str, session.get("id", ""))
2387
- directory = cast(str, session.get("directory", ""))
2388
2462
 
2389
2463
  # Check if we already have a mapping for this session
2390
2464
  if session_id in SESSION_AGENTS:
@@ -2392,8 +2466,12 @@ def get_or_create_agent_for_session(
2392
2466
  if agent_id in agents:
2393
2467
  return agents[agent_id]
2394
2468
 
2395
- # Generate unique agent ID from session
2396
- agent_id = generate_agent_id_for_session(session)
2469
+ directory = cast(str, session.get("directory", ""))
2470
+
2471
+ # Try to get the actual agent from the session's most recent message
2472
+ detected_agent = get_agent_from_session_messages(session_id)
2473
+ # Use detected agent, or fall back to session slug/ID
2474
+ agent_id = detected_agent or generate_agent_id_for_session(session)
2397
2475
 
2398
2476
  # Handle conflicts by appending session ID fragment
2399
2477
  if agent_id in agents and agents[agent_id].get("sessionId") != session_id:
@@ -2519,7 +2597,8 @@ def orient_session(
2519
2597
 
2520
2598
  # Inject minimal orientation to the agent
2521
2599
  orientation = format_orientation(agent, all_agents)
2522
- inject_message(session_id, orientation)
2600
+ # Orientation comes from the daemon, use the target agent's ID as context
2601
+ inject_message(session_id, orientation, agent=agent_id)
2523
2602
 
2524
2603
  # Notify coordinator of new agent (coordinator will reach out to capture task)
2525
2604
  notify_coordinator_new_agent(cast(str, agent_id), cast(str, directory))
@@ -2593,7 +2672,7 @@ def check_orientation_retries(agents: dict[str, dict[str, Any]]) -> None:
2593
2672
  agent or {"id": agent_id, "projectPath": ""},
2594
2673
  agents,
2595
2674
  )
2596
- inject_message(session_id, orientation)
2675
+ inject_message(session_id, orientation, agent=agent_id)
2597
2676
  pending["retries"] = retries + 1
2598
2677
  pending["oriented_at"] = now # Reset timer for next retry window
2599
2678
  metrics.inc("agent_hub_orientation_retries_total")
@@ -2795,9 +2874,13 @@ def process_message_file(path: Path, agents: dict[str, dict[str, Any]]) -> None:
2795
2874
  )
2796
2875
  if matching_sessions:
2797
2876
  notification = format_notification(msg, cast(str, agent["id"]))
2877
+ # Use the target agent's ID so the message is handled by their configured agent
2878
+ target_agent_id = cast(str, agent["id"])
2798
2879
  for session in matching_sessions:
2799
- log.info(f"Injecting message into session {session['id']} for agent {agent['id']}")
2800
- inject_message(cast(str, session["id"]), notification)
2880
+ log.info(
2881
+ f"Injecting message into session {session['id']} for agent {target_agent_id}"
2882
+ )
2883
+ inject_message(cast(str, session["id"]), notification, agent=target_agent_id)
2801
2884
  delivered = True
2802
2885
  else:
2803
2886
  log.info(f"No session found for agent {agent['id']}")
@@ -3115,6 +3198,7 @@ Examples:
3115
3198
  log.info(f"Watching agents: {AGENTS_DIR}")
3116
3199
  log.info(f"OpenCode API: {OPENCODE_URL}")
3117
3200
  log.info(f"Message TTL: {MESSAGE_TTL_SECONDS}s, GC interval: {GC_INTERVAL_SECONDS}s")
3201
+ log.info("Agent detection: enabled (detects agent from session messages)")
3118
3202
  if COORDINATOR_ENABLED:
3119
3203
  log.info(f"Coordinator: enabled, dir={COORDINATOR_DIR}")
3120
3204
  else:
@@ -0,0 +1,426 @@
1
+ """Tests for agent detection from SQLite database."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest import mock
8
+
9
+
10
+ def test_get_agent_from_session_messages_returns_agent() -> None:
11
+ """Verify agent is extracted from most recent message."""
12
+ from opencode_agent_hub import daemon
13
+
14
+ with tempfile.TemporaryDirectory() as tmpdir:
15
+ # Create a test database
16
+ db_path = Path(tmpdir) / "opencode.db"
17
+ conn = sqlite3.connect(str(db_path))
18
+ conn.execute("""
19
+ CREATE TABLE message (
20
+ id TEXT PRIMARY KEY,
21
+ session_id TEXT NOT NULL,
22
+ time_created INTEGER NOT NULL,
23
+ data TEXT NOT NULL
24
+ )
25
+ """)
26
+
27
+ # Insert a message with agent
28
+ message_data = json.dumps(
29
+ {"role": "assistant", "agent": "claude", "modelID": "claude-3-sonnet"}
30
+ )
31
+ conn.execute(
32
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
33
+ ("msg_123", "ses_test", 1700000000000, message_data),
34
+ )
35
+ conn.commit()
36
+ conn.close()
37
+
38
+ # Mock the database path
39
+ original_path = daemon.OPENCODE_DB_PATH
40
+ daemon.OPENCODE_DB_PATH = db_path
41
+
42
+ try:
43
+ agent = daemon.get_agent_from_session_messages("ses_test")
44
+ assert agent == "claude"
45
+ finally:
46
+ daemon.OPENCODE_DB_PATH = original_path
47
+
48
+
49
+ def test_get_agent_from_session_messages_no_messages() -> None:
50
+ """Verify None returned when session has no messages."""
51
+ from opencode_agent_hub import daemon
52
+
53
+ with tempfile.TemporaryDirectory() as tmpdir:
54
+ db_path = Path(tmpdir) / "opencode.db"
55
+ conn = sqlite3.connect(str(db_path))
56
+ conn.execute("""
57
+ CREATE TABLE message (
58
+ id TEXT PRIMARY KEY,
59
+ session_id TEXT NOT NULL,
60
+ time_created INTEGER NOT NULL,
61
+ data TEXT NOT NULL
62
+ )
63
+ """)
64
+ conn.commit()
65
+ conn.close()
66
+
67
+ original_path = daemon.OPENCODE_DB_PATH
68
+ daemon.OPENCODE_DB_PATH = db_path
69
+
70
+ try:
71
+ agent = daemon.get_agent_from_session_messages("ses_empty")
72
+ assert agent is None
73
+ finally:
74
+ daemon.OPENCODE_DB_PATH = original_path
75
+
76
+
77
+ def test_get_agent_from_session_messages_db_missing() -> None:
78
+ """Verify None returned when database doesn't exist."""
79
+ from opencode_agent_hub import daemon
80
+
81
+ original_path = daemon.OPENCODE_DB_PATH
82
+ daemon.OPENCODE_DB_PATH = Path("/nonexistent/opencode.db")
83
+
84
+ try:
85
+ agent = daemon.get_agent_from_session_messages("ses_test")
86
+ assert agent is None
87
+ finally:
88
+ daemon.OPENCODE_DB_PATH = original_path
89
+
90
+
91
+ def test_get_agent_from_session_messages_missing_agent_field() -> None:
92
+ """Verify None returned when message has no agent field."""
93
+ from opencode_agent_hub import daemon
94
+
95
+ with tempfile.TemporaryDirectory() as tmpdir:
96
+ db_path = Path(tmpdir) / "opencode.db"
97
+ conn = sqlite3.connect(str(db_path))
98
+ conn.execute("""
99
+ CREATE TABLE message (
100
+ id TEXT PRIMARY KEY,
101
+ session_id TEXT NOT NULL,
102
+ time_created INTEGER NOT NULL,
103
+ data TEXT NOT NULL
104
+ )
105
+ """)
106
+
107
+ # Insert message without agent field
108
+ message_data = json.dumps({"role": "user", "content": "hello"})
109
+ conn.execute(
110
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
111
+ ("msg_123", "ses_test", 1700000000000, message_data),
112
+ )
113
+ conn.commit()
114
+ conn.close()
115
+
116
+ original_path = daemon.OPENCODE_DB_PATH
117
+ daemon.OPENCODE_DB_PATH = db_path
118
+
119
+ try:
120
+ agent = daemon.get_agent_from_session_messages("ses_test")
121
+ assert agent is None
122
+ finally:
123
+ daemon.OPENCODE_DB_PATH = original_path
124
+
125
+
126
+ def test_get_agent_from_session_messages_uses_most_recent() -> None:
127
+ """Verify agent from most recent message is returned."""
128
+ from opencode_agent_hub import daemon
129
+
130
+ with tempfile.TemporaryDirectory() as tmpdir:
131
+ db_path = Path(tmpdir) / "opencode.db"
132
+ conn = sqlite3.connect(str(db_path))
133
+ conn.execute("""
134
+ CREATE TABLE message (
135
+ id TEXT PRIMARY KEY,
136
+ session_id TEXT NOT NULL,
137
+ time_created INTEGER NOT NULL,
138
+ data TEXT NOT NULL
139
+ )
140
+ """)
141
+
142
+ # Insert older message with different agent
143
+ old_message = json.dumps({"role": "assistant", "agent": "gpt-4"})
144
+ conn.execute(
145
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
146
+ ("msg_old", "ses_test", 1700000000000, old_message),
147
+ )
148
+
149
+ # Insert newer message with different agent
150
+ new_message = json.dumps({"role": "assistant", "agent": "kimi"})
151
+ conn.execute(
152
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
153
+ ("msg_new", "ses_test", 1700000001000, new_message),
154
+ )
155
+
156
+ conn.commit()
157
+ conn.close()
158
+
159
+ original_path = daemon.OPENCODE_DB_PATH
160
+ daemon.OPENCODE_DB_PATH = db_path
161
+
162
+ try:
163
+ agent = daemon.get_agent_from_session_messages("ses_test")
164
+ assert agent == "kimi" # Should return newest, not oldest
165
+ finally:
166
+ daemon.OPENCODE_DB_PATH = original_path
167
+
168
+
169
+ def test_get_agent_from_session_messages_invalid_json() -> None:
170
+ """Verify None returned when message data is invalid JSON."""
171
+ from opencode_agent_hub import daemon
172
+
173
+ with tempfile.TemporaryDirectory() as tmpdir:
174
+ db_path = Path(tmpdir) / "opencode.db"
175
+ conn = sqlite3.connect(str(db_path))
176
+ conn.execute("""
177
+ CREATE TABLE message (
178
+ id TEXT PRIMARY KEY,
179
+ session_id TEXT NOT NULL,
180
+ time_created INTEGER NOT NULL,
181
+ data TEXT NOT NULL
182
+ )
183
+ """)
184
+
185
+ # Insert message with invalid JSON
186
+ conn.execute(
187
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
188
+ ("msg_123", "ses_test", 1700000000000, "not valid json"),
189
+ )
190
+ conn.commit()
191
+ conn.close()
192
+
193
+ original_path = daemon.OPENCODE_DB_PATH
194
+ daemon.OPENCODE_DB_PATH = db_path
195
+
196
+ try:
197
+ agent = daemon.get_agent_from_session_messages("ses_test")
198
+ assert agent is None
199
+ finally:
200
+ daemon.OPENCODE_DB_PATH = original_path
201
+
202
+
203
+ def test_get_agent_from_session_messages_agent_not_string() -> None:
204
+ """Verify None returned when agent is not a string."""
205
+ from opencode_agent_hub import daemon
206
+
207
+ with tempfile.TemporaryDirectory() as tmpdir:
208
+ db_path = Path(tmpdir) / "opencode.db"
209
+ conn = sqlite3.connect(str(db_path))
210
+ conn.execute("""
211
+ CREATE TABLE message (
212
+ id TEXT PRIMARY KEY,
213
+ session_id TEXT NOT NULL,
214
+ time_created INTEGER NOT NULL,
215
+ data TEXT NOT NULL
216
+ )
217
+ """)
218
+
219
+ # Insert message with agent as object (not string)
220
+ message_data = json.dumps({"role": "assistant", "agent": {"name": "kimi"}})
221
+ conn.execute(
222
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
223
+ ("msg_123", "ses_test", 1700000000000, message_data),
224
+ )
225
+ conn.commit()
226
+ conn.close()
227
+
228
+ original_path = daemon.OPENCODE_DB_PATH
229
+ daemon.OPENCODE_DB_PATH = db_path
230
+
231
+ try:
232
+ agent = daemon.get_agent_from_session_messages("ses_test")
233
+ assert agent is None
234
+ finally:
235
+ daemon.OPENCODE_DB_PATH = original_path
236
+
237
+
238
+ def test_get_agent_from_session_messages_empty_string() -> None:
239
+ """Verify None returned when agent is empty string."""
240
+ from opencode_agent_hub import daemon
241
+
242
+ with tempfile.TemporaryDirectory() as tmpdir:
243
+ db_path = Path(tmpdir) / "opencode.db"
244
+ conn = sqlite3.connect(str(db_path))
245
+ conn.execute("""
246
+ CREATE TABLE message (
247
+ id TEXT PRIMARY KEY,
248
+ session_id TEXT NOT NULL,
249
+ time_created INTEGER NOT NULL,
250
+ data TEXT NOT NULL
251
+ )
252
+ """)
253
+
254
+ # Insert message with empty agent string
255
+ message_data = json.dumps({"role": "assistant", "agent": ""})
256
+ conn.execute(
257
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
258
+ ("msg_123", "ses_test", 1700000000000, message_data),
259
+ )
260
+ conn.commit()
261
+ conn.close()
262
+
263
+ original_path = daemon.OPENCODE_DB_PATH
264
+ daemon.OPENCODE_DB_PATH = db_path
265
+
266
+ try:
267
+ agent = daemon.get_agent_from_session_messages("ses_test")
268
+ assert agent is None
269
+ finally:
270
+ daemon.OPENCODE_DB_PATH = original_path
271
+
272
+
273
+ def test_get_agent_from_session_messages_null() -> None:
274
+ """Verify None returned when agent is null."""
275
+ from opencode_agent_hub import daemon
276
+
277
+ with tempfile.TemporaryDirectory() as tmpdir:
278
+ db_path = Path(tmpdir) / "opencode.db"
279
+ conn = sqlite3.connect(str(db_path))
280
+ conn.execute("""
281
+ CREATE TABLE message (
282
+ id TEXT PRIMARY KEY,
283
+ session_id TEXT NOT NULL,
284
+ time_created INTEGER NOT NULL,
285
+ data TEXT NOT NULL
286
+ )
287
+ """)
288
+
289
+ # Insert message with null agent
290
+ message_data = json.dumps({"role": "assistant", "agent": None})
291
+ conn.execute(
292
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
293
+ ("msg_123", "ses_test", 1700000000000, message_data),
294
+ )
295
+ conn.commit()
296
+ conn.close()
297
+
298
+ original_path = daemon.OPENCODE_DB_PATH
299
+ daemon.OPENCODE_DB_PATH = db_path
300
+
301
+ try:
302
+ agent = daemon.get_agent_from_session_messages("ses_test")
303
+ assert agent is None
304
+ finally:
305
+ daemon.OPENCODE_DB_PATH = original_path
306
+
307
+
308
+ def test_get_agent_from_session_messages_db_locked() -> None:
309
+ """Verify None returned gracefully when database is locked."""
310
+ from opencode_agent_hub import daemon
311
+
312
+ # Mock sqlite3.connect to raise OperationalError
313
+ with mock.patch("sqlite3.connect", side_effect=sqlite3.OperationalError("database is locked")):
314
+ agent = daemon.get_agent_from_session_messages("ses_test")
315
+ assert agent is None
316
+
317
+
318
+ def test_get_or_create_agent_uses_detected_agent_from_db() -> None:
319
+ """Verify agent from database is used when available."""
320
+ from opencode_agent_hub import daemon
321
+
322
+ with tempfile.TemporaryDirectory() as tmpdir:
323
+ # Set up test database with agent
324
+ db_path = Path(tmpdir) / "opencode.db"
325
+ conn = sqlite3.connect(str(db_path))
326
+ conn.execute("""
327
+ CREATE TABLE message (
328
+ id TEXT PRIMARY KEY,
329
+ session_id TEXT NOT NULL,
330
+ time_created INTEGER NOT NULL,
331
+ data TEXT NOT NULL
332
+ )
333
+ """)
334
+ message_data = json.dumps({"role": "assistant", "agent": "custom-agent"})
335
+ conn.execute(
336
+ "INSERT INTO message VALUES (?, ?, ?, ?)",
337
+ ("msg_123", "ses_test123", 1700000000000, message_data),
338
+ )
339
+ conn.commit()
340
+ conn.close()
341
+
342
+ original_path = daemon.OPENCODE_DB_PATH
343
+ daemon.OPENCODE_DB_PATH = db_path
344
+ daemon.SESSION_AGENTS = {}
345
+
346
+ try:
347
+ session = {
348
+ "id": "ses_test123",
349
+ "slug": "test-slug",
350
+ "directory": "/home/user/project",
351
+ }
352
+ agents: dict[str, dict] = {}
353
+
354
+ agent = daemon.get_or_create_agent_for_session(session, agents)
355
+
356
+ # Should use detected agent, not slug
357
+ assert agent["id"] == "custom-agent"
358
+ assert agent["sessionId"] == "ses_test123"
359
+ finally:
360
+ daemon.OPENCODE_DB_PATH = original_path
361
+ daemon.SESSION_AGENTS = {}
362
+
363
+
364
+ def test_get_or_create_agent_falls_back_to_slug_when_no_db_agent() -> None:
365
+ """Verify slug is used when no agent in database."""
366
+ from opencode_agent_hub import daemon
367
+
368
+ with tempfile.TemporaryDirectory() as tmpdir:
369
+ # Empty database - no messages
370
+ db_path = Path(tmpdir) / "opencode.db"
371
+ conn = sqlite3.connect(str(db_path))
372
+ conn.execute("""
373
+ CREATE TABLE message (
374
+ id TEXT PRIMARY KEY,
375
+ session_id TEXT NOT NULL,
376
+ time_created INTEGER NOT NULL,
377
+ data TEXT NOT NULL
378
+ )
379
+ """)
380
+ conn.commit()
381
+ conn.close()
382
+
383
+ original_path = daemon.OPENCODE_DB_PATH
384
+ daemon.OPENCODE_DB_PATH = db_path
385
+ daemon.SESSION_AGENTS = {}
386
+
387
+ try:
388
+ session = {
389
+ "id": "ses_test456",
390
+ "slug": "fallback-slug",
391
+ "directory": "/home/user/project",
392
+ }
393
+ agents: dict[str, dict] = {}
394
+
395
+ agent = daemon.get_or_create_agent_for_session(session, agents)
396
+
397
+ # Should fall back to slug
398
+ assert agent["id"] == "fallback-slug"
399
+ finally:
400
+ daemon.OPENCODE_DB_PATH = original_path
401
+ daemon.SESSION_AGENTS = {}
402
+
403
+
404
+ def test_get_or_create_agent_falls_back_when_db_missing() -> None:
405
+ """Verify slug is used when database doesn't exist."""
406
+ from opencode_agent_hub import daemon
407
+
408
+ original_path = daemon.OPENCODE_DB_PATH
409
+ daemon.OPENCODE_DB_PATH = Path("/nonexistent/opencode.db")
410
+ daemon.SESSION_AGENTS = {}
411
+
412
+ try:
413
+ session = {
414
+ "id": "ses_test789",
415
+ "slug": "no-db-slug",
416
+ "directory": "/home/user/project",
417
+ }
418
+ agents: dict[str, dict] = {}
419
+
420
+ agent = daemon.get_or_create_agent_for_session(session, agents)
421
+
422
+ # Should fall back to slug
423
+ assert agent["id"] == "no-db-slug"
424
+ finally:
425
+ daemon.OPENCODE_DB_PATH = original_path
426
+ daemon.SESSION_AGENTS = {}