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.
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/PKG-INFO +1 -1
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/pyproject.toml +1 -1
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/__init__.py +1 -1
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/daemon.py +102 -18
- opencode_agent_hub-1.4.1/tests/test_agent_detection.py +426 -0
- opencode_agent_hub-1.4.1/tests/test_integration.py +338 -0
- opencode_agent_hub-1.4.1/tests/test_message_injection.py +285 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_orientation_retry.py +3 -3
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/.gitignore +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/LICENSE +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/README.md +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/coordinator/AGENTS.md +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/coordinator/opencode.json +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/launchd/com.xnoto.agent-hub-daemon.plist +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/aur/PKGBUILD +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/changelog +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/control +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/copyright +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/docs +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/install +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/postinst +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/rules +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/debian/source/format +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/packaging/rpm/opencode-agent-hub.spec +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/contrib/systemd/agent-hub-daemon.service +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/py.typed +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/src/opencode_agent_hub/watch.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/__init__.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_config.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_coordinator.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_coordinator_cost.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_placeholder.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_rate_limiting.py +0 -0
- {opencode_agent_hub-1.3.3 → opencode_agent_hub-1.4.1}/tests/test_session_agents.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2384
|
-
|
|
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
|
-
|
|
2396
|
-
|
|
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
|
-
|
|
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(
|
|
2800
|
-
|
|
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 = {}
|