Compare commits
8
Commits
1d7b7dab58
...
6a4e385a78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4e385a78 | ||
|
|
d17cd80bd9 | ||
|
|
7803d47026 | ||
|
|
463817ee34 | ||
|
|
614ab1420f | ||
|
|
ac5f1154c7 | ||
|
|
14d7a46181 | ||
|
|
4d49abb371 |
No files matched your search
@@ -117,6 +117,15 @@ def main():
|
|||||||
print(adapter.resume_spec(binary, uuid, materialized=mat))
|
print(adapter.resume_spec(binary, uuid, materialized=mat))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif cmd == 'auth-ok':
|
||||||
|
# auth-ok <agent>: exit 0 when the adapter's auth_ok() holds, else 1.
|
||||||
|
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
|
adapter = get_adapter(agent_name)
|
||||||
|
if not adapter:
|
||||||
|
print(f"ERROR: unknown agent {agent_name!r}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0 if adapter.auth_ok() else 1)
|
||||||
|
|
||||||
elif cmd == 'exit-key':
|
elif cmd == 'exit-key':
|
||||||
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||||
adapter = get_adapter(agent_name)
|
adapter = get_adapter(agent_name)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import os, sys, sqlite3, shutil, subprocess
|
import os, sys, sqlite3, shutil, subprocess, tempfile
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from typing import Optional, Any, List
|
from typing import Optional, Any, List
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming, conversation_in_use
|
||||||
@@ -10,12 +10,34 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
|
|||||||
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
||||||
("session", "id"), ("sessions", "id"))
|
("session", "id"), ("sessions", "id"))
|
||||||
|
|
||||||
_PROVIDER_ENV_VARS = (
|
def _ro_connect(db: str):
|
||||||
'ANTHROPIC_API_KEY',
|
"""A read that neither creates nor removes anything beside db (Issue #60).
|
||||||
'OPENAI_API_KEY',
|
Both WAL sidecars (a live writer keeps them): read through them. No -wal:
|
||||||
'GEMINI_API_KEY',
|
nothing to apply, so the file is read as immutable. A -wal without its
|
||||||
'GROQ_API_KEY'
|
-shm (left behind - no writer runs without one) still holds committed
|
||||||
)
|
rows: they are read from a private copy, where SQLite may rebuild the
|
||||||
|
index, loaded into memory."""
|
||||||
|
path = os.path.abspath(db)
|
||||||
|
wal, shm = os.path.exists(path + "-wal"), os.path.exists(path + "-shm")
|
||||||
|
if wal and shm:
|
||||||
|
return sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True)
|
||||||
|
if not wal:
|
||||||
|
return sqlite3.connect(f"file:{quote(path)}?mode=ro&immutable=1", uri=True)
|
||||||
|
mem = sqlite3.connect(":memory:")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mam-opencode-") as tmp:
|
||||||
|
copy = os.path.join(tmp, "opencode.db")
|
||||||
|
shutil.copyfile(path, copy)
|
||||||
|
shutil.copyfile(path + "-wal", copy + "-wal")
|
||||||
|
src = sqlite3.connect(copy)
|
||||||
|
try:
|
||||||
|
src.backup(mem)
|
||||||
|
finally:
|
||||||
|
src.close()
|
||||||
|
return mem
|
||||||
|
|
||||||
|
|
||||||
|
# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them.
|
||||||
|
_DB_PATHS = {}
|
||||||
|
|
||||||
class OpenCodeAgentAdapter(BaseAgentAdapter):
|
class OpenCodeAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -50,6 +72,12 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
def input_placeholder(self) -> Optional[str]:
|
def input_placeholder(self) -> Optional[str]:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_placeholder_pattern(self) -> Optional[str]:
|
||||||
|
# 2.x home screen: `Ask anything… "<example>"`, the example picked per
|
||||||
|
# launch and cut without its closing quote in a narrow box (Issue #60).
|
||||||
|
return r'Ask anything(?:…|\.\.\.)(?: "[^"]*"?)?'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_rule_pattern(self) -> Optional[str]:
|
def input_rule_pattern(self) -> Optional[str]:
|
||||||
# Kept for lib.sh's readiness corroboration; the input box itself is a
|
# Kept for lib.sh's readiness corroboration; the input box itself is a
|
||||||
@@ -111,13 +139,23 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
# opencode 2.x prints its paths with `debug paths` (the 1.x `db path`
|
# opencode 2.x prints its paths with `debug paths` (the 1.x `db path`
|
||||||
# is gone: it prints the help and exits 0). Fallback: the default.
|
# is gone: it prints the help and exits 0). Fallback: the default.
|
||||||
|
# Each run writes a line to opencode's log, so a process asks once
|
||||||
|
# per (binary, HOME, XDG_DATA_HOME) the run would see (Issue #60).
|
||||||
|
bin_path = self._bin(ctx)
|
||||||
|
if ctx.home_dir:
|
||||||
|
key = (bin_path, ctx.home_dir, self._data_home(ctx))
|
||||||
|
else:
|
||||||
|
key = (bin_path, os.environ.get("HOME"), os.environ.get("XDG_DATA_HOME"))
|
||||||
|
if bin_path and key in _DB_PATHS:
|
||||||
|
return _DB_PATHS[key]
|
||||||
try:
|
try:
|
||||||
res = self._run(ctx, ["debug", "paths"], timeout=5)
|
res = self._run(ctx, ["debug", "paths"], timeout=5)
|
||||||
if res is not None and res.returncode == 0:
|
if res is not None and res.returncode == 0:
|
||||||
for line in res.stdout.splitlines():
|
for line in res.stdout.splitlines():
|
||||||
parts = line.split(None, 1)
|
parts = line.split(None, 1)
|
||||||
if len(parts) == 2 and parts[0] == "db":
|
if len(parts) == 2 and parts[0] == "db":
|
||||||
return parts[1].strip()
|
_DB_PATHS[key] = parts[1].strip()
|
||||||
|
return _DB_PATHS[key]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return os.path.join(self._data_home(ctx), "opencode", "opencode.db")
|
return os.path.join(self._data_home(ctx), "opencode", "opencode.db")
|
||||||
@@ -133,7 +171,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
try:
|
try:
|
||||||
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
|
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
|
||||||
# survives a migration but is no longer written).
|
# survives a migration but is no longer written).
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not r:
|
if not r:
|
||||||
@@ -196,7 +234,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
|
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
|
||||||
found, todo = [], [uuid]
|
found, todo = [], [uuid]
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
conn = _ro_connect(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return found
|
return found
|
||||||
try:
|
try:
|
||||||
@@ -218,7 +256,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
|
|
||||||
def _has_session(self, db: str, uuid: str) -> bool:
|
def _has_session(self, db: str, uuid: str) -> bool:
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
try:
|
try:
|
||||||
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
||||||
finally:
|
finally:
|
||||||
@@ -233,7 +271,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid)
|
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid)
|
||||||
for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []):
|
for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []):
|
||||||
rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
|
rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
|
||||||
id_tables=(("session", "id"), ("session_v2", "id")))
|
id_tables=(("session", "id"), ("session_v2", "id")), connect=_ro_connect)
|
||||||
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
|
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
|
||||||
return found
|
return found
|
||||||
|
|
||||||
@@ -277,11 +315,10 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
return f"{binary} --auto --standalone"
|
return f"{binary} --auto --standalone"
|
||||||
|
|
||||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||||
if any(os.environ.get(v) for v in _PROVIDER_ENV_VARS):
|
# opencode 2.x needs no login: a fresh HOME runs a free default model
|
||||||
return True
|
# and local providers have no key, so the binary is the whole check.
|
||||||
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||||
auth_file = f"{home}/.local/share/opencode/auth.json"
|
return bool(shutil.which("opencode")) or os.path.exists(f"{home}/.opencode/bin/opencode")
|
||||||
return os.path.exists(auth_file)
|
|
||||||
|
|
||||||
def discover(self, ctx: DiscoveryContext) -> list:
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
db = self._resolve_db(ctx)
|
db = self._resolve_db(ctx)
|
||||||
@@ -300,7 +337,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
params.append(int(float(ctx.epoch) * 1000))
|
params.append(int(float(ctx.epoch) * 1000))
|
||||||
query += " ORDER BY time_created DESC LIMIT 20"
|
query += " ORDER BY time_created DESC LIMIT 20"
|
||||||
|
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
rows = conn.execute(query, params).fetchall()
|
rows = conn.execute(query, params).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|||||||
@@ -108,14 +108,15 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
|
|||||||
return sorted(found)
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list:
|
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
|
||||||
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
||||||
conversation in one of `columns`, or in the id column of an `id_tables`
|
conversation in one of `columns`, or in the id column of an `id_tables`
|
||||||
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped."""
|
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
|
||||||
|
`connect(db)` opens the database (default: a mode=ro URI)."""
|
||||||
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
conn = connect(db) if connect else sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
found = []
|
found = []
|
||||||
@@ -208,6 +209,13 @@ class BaseAgentAdapter:
|
|||||||
def input_placeholder(self) -> Optional[str]:
|
def input_placeholder(self) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_placeholder_pattern(self) -> Optional[str]:
|
||||||
|
"""Regex the input box's first line matches in full while it shows
|
||||||
|
only a placeholder whose text varies (#60); input_placeholder stays
|
||||||
|
the literal lib.sh greps for."""
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_rule_pattern(self) -> Optional[str]:
|
def input_rule_pattern(self) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
|
|||||||
prompt line is input: a multi-line draft is never cut to its first line.
|
prompt line is input: a multi-line draft is never cut to its first line.
|
||||||
|
|
||||||
Then `input_frame_chars` are stripped, and the prompt and the placeholder
|
Then `input_frame_chars` are stripped, and the prompt and the placeholder
|
||||||
(whole-line) are stripped from the box's first non-blank line only. There
|
(whole-line: `input_placeholder` exactly or `input_placeholder_pattern` in
|
||||||
|
full) are stripped from the box's first non-blank line only. There
|
||||||
is no whole-pane fallback: a pane with none of these shapes has no input
|
is no whole-pane fallback: a pane with none of these shapes has no input
|
||||||
region and yields a non-empty "unknown" result rather than a guess.
|
region and yields a non-empty "unknown" result rather than a guess.
|
||||||
Raises ValueError for agents without input area facts (like hermes' peers).
|
Raises ValueError for agents without input area facts (like hermes' peers).
|
||||||
@@ -79,7 +80,8 @@ def extract_input_region(pane_text: str, agent_name: str) -> str:
|
|||||||
if first is not None:
|
if first is not None:
|
||||||
head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1)
|
head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1)
|
||||||
placeholder = adapter.input_placeholder
|
placeholder = adapter.input_placeholder
|
||||||
if placeholder and head.strip() == placeholder:
|
pattern = adapter.input_placeholder_pattern
|
||||||
|
if (placeholder and head.strip() == placeholder) or (pattern and re.fullmatch(pattern, head.strip())):
|
||||||
head = ''
|
head = ''
|
||||||
region_lines[first] = head
|
region_lines[first] = head
|
||||||
|
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ elif [ "$AGENT" = "grok" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
elif [ "$AGENT" = "opencode" ]; then
|
elif [ "$AGENT" = "opencode" ]; then
|
||||||
if [ -f "$HOME/.local/share/opencode/auth.json" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GROQ_API_KEY:-}" ]; then
|
# opencode needs no login (2.x: a free default model, keyless local
|
||||||
true
|
# providers); the adapter's auth_ok is the one verdict.
|
||||||
elif ! opencode --version >/dev/null 2>&1; then
|
if ! "$(_delegate_py_bin)" -m lib_py.agents auth-ok opencode >/dev/null 2>&1; then
|
||||||
echo "ERROR: opencode is not functional or configured." >&2
|
echo "ERROR: opencode is not functional or configured." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ graceful_stop() {
|
|||||||
if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then
|
if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then
|
||||||
herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true
|
herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true
|
||||||
else
|
else
|
||||||
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
|
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: delivery of '$exitkey' unconfirmed (rc=$?) — waiting for the session to exit"
|
||||||
fi
|
fi
|
||||||
_wait_session_gone "$SESSION_NAME" 5 || true
|
_wait_session_gone "$SESSION_NAME" 5 || true
|
||||||
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# captured 2026-10-18 from an idle opencode 2.0.32 home screen (no message sent), input box empty: herdr 0.9.3 `herdr agent read <session> --source visible`, 140x40 (cwd line masked)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
▄
|
||||||
|
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||||
|
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||||
|
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||||
|
|
||||||
|
|
||||||
|
┃
|
||||||
|
┃ Ask anything… "Fix a TODO in the codebase"
|
||||||
|
┃
|
||||||
|
┃ Build auto · Nemotron 3.5 Super VL Free OpenCode
|
||||||
|
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||||
|
/…/<repo>/<worktree>:<branch> shift+tab agents ctrl+p commands
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
2.0.32
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# captured 2026-10-18 from an idle opencode 2.0.32 home screen (no message sent), input box empty, width 60: tmux 60x30 `capture-pane -p`; the example is cut without its closing quote (cwd line masked)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
▄
|
||||||
|
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||||
|
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||||
|
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||||
|
|
||||||
|
|
||||||
|
┃
|
||||||
|
┃ Ask anything… "What is the tech stack of this proje
|
||||||
|
┃
|
||||||
|
┃ Build · Nemotron 3.5 Super VL Free OpenCode
|
||||||
|
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||||
|
/…/<worktree>:<branch> shift+tab agents ctrl+p commands
|
||||||
@@ -311,16 +311,24 @@ def test_adapter_auth_ok(tmp_path, monkeypatch):
|
|||||||
# Hermes always True
|
# Hermes always True
|
||||||
assert get_adapter('hermes').auth_ok() is True
|
assert get_adapter('hermes').auth_ok() is True
|
||||||
|
|
||||||
# OpenCode auth check (provider env var or auth.json file)
|
# OpenCode auth check: the binary alone (2.x needs no login, Issue #60);
|
||||||
|
# keys and auth.json do not count
|
||||||
opencode = get_adapter('opencode')
|
opencode = get_adapter('opencode')
|
||||||
|
path = os.environ["PATH"]
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
|
||||||
assert opencode.auth_ok() is False
|
assert opencode.auth_ok() is False
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||||
assert opencode.auth_ok() is True
|
|
||||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
||||||
opencode_auth = tmp_path / ".local" / "share" / "opencode" / "auth.json"
|
opencode_auth = tmp_path / ".local" / "share" / "opencode" / "auth.json"
|
||||||
opencode_auth.parent.mkdir(parents=True, exist_ok=True)
|
opencode_auth.parent.mkdir(parents=True, exist_ok=True)
|
||||||
opencode_auth.write_text("{}")
|
opencode_auth.write_text("{}")
|
||||||
|
assert opencode.auth_ok() is False
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
opencode_bin = tmp_path / ".opencode" / "bin" / "opencode"
|
||||||
|
opencode_bin.parent.mkdir(parents=True)
|
||||||
|
opencode_bin.write_text("#!/bin/sh\n")
|
||||||
|
opencode_bin.chmod(0o755)
|
||||||
assert opencode.auth_ok() is True
|
assert opencode.auth_ok() is True
|
||||||
|
monkeypatch.setenv("PATH", path)
|
||||||
|
|
||||||
# Codex auth check (env var, auth.json file, or CLI)
|
# Codex auth check (env var, auth.json file, or CLI)
|
||||||
codex = get_adapter('codex')
|
codex = get_adapter('codex')
|
||||||
|
|||||||
@@ -165,9 +165,11 @@ def test_u1e_a_dry_run_record_is_not_an_open_job(tmp_path):
|
|||||||
admission must not wait for it."""
|
admission must not wait for it."""
|
||||||
reg = tmp_path / "jobs"
|
reg = tmp_path / "jobs"
|
||||||
# WORKSPACE_ROOT: the wrapper sources lib.sh, which otherwise writes the
|
# WORKSPACE_ROOT: the wrapper sources lib.sh, which otherwise writes the
|
||||||
# checkout's own .mam/shim/herdr (Issue #17 follow-up; W1 runs this node)
|
# checkout's own .mam/shim/herdr (Issue #17 follow-up; W1 runs this node).
|
||||||
|
# DELEGATE_JOB_PYTHON: from tmp_path the wrapper finds no .venv and would
|
||||||
|
# take PATH's python3, which may lack paho (Issue #60, Linux).
|
||||||
env = dict(os.environ, HOME=str(tmp_path), MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"),
|
env = dict(os.environ, HOME=str(tmp_path), MAM_ENV_FILE=str(tmp_path / ".mam.env.absent"),
|
||||||
WORKSPACE_ROOT=str(tmp_path))
|
WORKSPACE_ROOT=str(tmp_path), DELEGATE_JOB_PYTHON=sys.executable)
|
||||||
res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:s",
|
res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:s",
|
||||||
"--dry-run", "--registry-dir", str(reg), "--prompt", "preview only"],
|
"--dry-run", "--registry-dir", str(reg), "--prompt", "preview only"],
|
||||||
capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=120)
|
capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=120)
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Issue #60, Linux check: the defects the opencode 2.x check on Linux found.
|
||||||
|
|
||||||
|
1. The 2.x home screen's placeholder (`Ask anything… "<example>"`, cut
|
||||||
|
without its closing quote in a narrow box) is not typed input.
|
||||||
|
5. The adapter's queries neither create nor remove anything beside
|
||||||
|
opencode's database (a read-write open checkpoints the WAL and deletes
|
||||||
|
the sidecars on close; a plain mode=ro open creates missing ones).
|
||||||
|
6. `debug paths` (which writes a line to opencode's log) runs once per
|
||||||
|
(binary, HOME, XDG_DATA_HOME) in a process.
|
||||||
|
2. A graceful stop whose `/exit` worked says so: the agent exits instead of
|
||||||
|
turning `working`, so the delivery is unconfirmed, and no kill step ran.
|
||||||
|
3. opencode's auth check is the binary alone (2.x needs no login), and create
|
||||||
|
takes the adapter's verdict instead of running `opencode --version`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||||
|
PANES = REPO_ROOT / "tests" / "fixtures" / "panes"
|
||||||
|
if str(SKILLS) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SKILLS))
|
||||||
|
|
||||||
|
from lib_py import stall_watch # noqa: E402
|
||||||
|
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
||||||
|
from lib_py.agents.input_region import extract_input_region # noqa: E402
|
||||||
|
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||||
|
from lib_py.agents.adapters import opencode as opencode_mod # noqa: E402
|
||||||
|
import _fake_opencode # noqa: E402
|
||||||
|
|
||||||
|
# 140 columns (herdr capture) and 60 columns, where the example loses its
|
||||||
|
# closing quote.
|
||||||
|
HOME_SCREENS = ("opencode_idle_2x_home.txt", "opencode_idle_2x_home_narrow.txt")
|
||||||
|
PLACEHOLDER_LINE = re.compile(r'^(\s*┃\s*)Ask anything… ".*$', re.M)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_cached_db_paths(monkeypatch):
|
||||||
|
monkeypatch.setattr(opencode_mod, "_DB_PATHS", {}, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _pane(name: str) -> str:
|
||||||
|
return "".join(l for l in (PANES / name).read_text(encoding="utf-8").splitlines(True) if not l.startswith("# "))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. the 2.x home screen's placeholder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.parametrize("name", HOME_SCREENS)
|
||||||
|
def test_the_2x_home_screen_reads_as_an_empty_box(name):
|
||||||
|
assert extract_input_region(_pane(name), "opencode") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_narrow_capture_has_a_cut_example():
|
||||||
|
# The case the pattern must keep: no closing quote on screen.
|
||||||
|
(line,) = [l for l in _pane(HOME_SCREENS[1]).splitlines() if "Ask anything" in l]
|
||||||
|
assert '"' in line and not line.rstrip().endswith('"')
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_narrow_screen_stops_at_its_status_line_and_reads_ready():
|
||||||
|
text = _pane(HOME_SCREENS[1])
|
||||||
|
# Under ~60 columns the status line drops `auto`: `Build · <model>`.
|
||||||
|
assert re.search(r"^\s*┃\s*Build · ", text, re.M) and "Build auto" not in text
|
||||||
|
assert re.search(get_adapter("opencode").ready_tokens, text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", HOME_SCREENS)
|
||||||
|
@pytest.mark.parametrize("typed", [
|
||||||
|
"please continue",
|
||||||
|
'Ask anything… "x" and fix it',
|
||||||
|
"Ask anything about the build",
|
||||||
|
'Ask anything… "a"b',
|
||||||
|
])
|
||||||
|
def test_typed_text_is_read_as_typed(name, typed):
|
||||||
|
text, n = PLACEHOLDER_LINE.subn(lambda m: m.group(1) + typed, _pane(name), count=1)
|
||||||
|
assert n == 1
|
||||||
|
assert extract_input_region(text, "opencode") == typed
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_1x_capture_still_reads_empty():
|
||||||
|
assert extract_input_region(_pane("opencode_idle.txt"), "opencode") == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", HOME_SCREENS)
|
||||||
|
def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
|
||||||
|
src = stall_watch.HerdrSource("t", "opencode")
|
||||||
|
monkeypatch.setattr(src, "_herdr", lambda *args: _pane(name))
|
||||||
|
assert src.input_empty() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_adapters_have_no_placeholder_pattern():
|
||||||
|
for agent in ("claude", "agy", "grok", "codex", "hermes"):
|
||||||
|
assert get_adapter(agent).input_placeholder_pattern is None, agent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. the queries leave the files beside the database alone
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)",
|
||||||
|
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||||
|
|
||||||
|
QUERIES = {
|
||||||
|
"verify_artifact": (lambda a, ctx, db: a.verify_artifact("ses_main", ctx), True),
|
||||||
|
"discover": (lambda a, ctx, db: a.discover(ctx), ["ses_main"]),
|
||||||
|
"_has_session": (lambda a, ctx, db: a._has_session(db, "ses_main"), True),
|
||||||
|
"_descendants": (lambda a, ctx, db: a._descendants(db, "ses_main"), ["ses_child"]),
|
||||||
|
"residual_artifacts": (lambda a, ctx, db: any("session_v2.id: 1 rows" in r
|
||||||
|
for r in a.residual_artifacts("ses_main", ctx)), True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wal_writer(db: Path, ws: Path) -> sqlite3.Connection:
|
||||||
|
"""An open WAL connection holding the schema and two sessions: until it
|
||||||
|
closes, the rows live in the -wal only."""
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA journal_mode=wal")
|
||||||
|
for stmt in V2_DDL:
|
||||||
|
conn.execute(stmt)
|
||||||
|
conn.execute("INSERT INTO session_v2 VALUES ('ses_main', NULL, ?, 2000000)", (str(ws),))
|
||||||
|
conn.execute("INSERT INTO session_v2 VALUES ('ses_child', 'ses_main', ?, 3000000)", (str(ws),))
|
||||||
|
conn.execute("INSERT INTO session_message VALUES ('msg-1', 'ses_main')")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wal_home(tmp_path, monkeypatch):
|
||||||
|
"""(adapter, ctx, db, ws, make(state)) - make builds the database in one
|
||||||
|
of four states: `clean` (closed: no sidecars), `leftover` (both sidecars
|
||||||
|
left behind by a writer that is gone, the rows only in the -wal),
|
||||||
|
`walonly` (the same without its -shm: the committed rows still count),
|
||||||
|
`writer` (a live writer whose rows are only in the -wal)."""
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
||||||
|
db.parent.mkdir(parents=True)
|
||||||
|
adapter = get_adapter("opencode")
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: None) # the default path, no subprocess
|
||||||
|
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def make(state):
|
||||||
|
if state == "clean":
|
||||||
|
_wal_writer(db, ws).close()
|
||||||
|
elif state in ("leftover", "walonly"):
|
||||||
|
src = tmp_path / "src" / "opencode.db"
|
||||||
|
src.parent.mkdir()
|
||||||
|
conn = _wal_writer(src, ws)
|
||||||
|
for suffix in ("", "-wal", "-shm")[:3 if state == "leftover" else 2]:
|
||||||
|
shutil.copyfile(str(src) + suffix, str(db) + suffix)
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
opened.append(_wal_writer(db, ws))
|
||||||
|
return sorted(os.listdir(db.parent))
|
||||||
|
|
||||||
|
yield adapter, ctx, str(db), ws, make
|
||||||
|
for conn in opened:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("query", sorted(QUERIES))
|
||||||
|
@pytest.mark.parametrize("state,listing", [
|
||||||
|
("clean", ["opencode.db"]),
|
||||||
|
("leftover", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]),
|
||||||
|
("walonly", ["opencode.db", "opencode.db-wal"]),
|
||||||
|
("writer", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]),
|
||||||
|
])
|
||||||
|
def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, listing, query):
|
||||||
|
adapter, ctx, db, ws, make = wal_home
|
||||||
|
assert make(state) == listing
|
||||||
|
run, expected = QUERIES[query]
|
||||||
|
assert run(adapter, ctx, db) == expected
|
||||||
|
assert sorted(os.listdir(os.path.dirname(db))) == listing
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. `debug paths` once per (binary, HOME, XDG_DATA_HOME)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_debug_paths_runs_once_per_home(tmp_path, monkeypatch):
|
||||||
|
log = tmp_path / "calls.log"
|
||||||
|
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode-real")
|
||||||
|
wrapper = tmp_path / "fakebin" / "opencode"
|
||||||
|
wrapper.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{fake}" "$@"\n')
|
||||||
|
wrapper.chmod(0o755)
|
||||||
|
adapter = get_adapter("opencode")
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(wrapper))
|
||||||
|
homes = [tmp_path / "home1", tmp_path / "home2"]
|
||||||
|
ctxs = [DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(h)) for h in homes]
|
||||||
|
|
||||||
|
def calls():
|
||||||
|
return log.read_text().splitlines() if log.exists() else []
|
||||||
|
|
||||||
|
want = [str(h / ".local" / "share" / "opencode" / "opencode.db") for h in homes]
|
||||||
|
assert [adapter._resolve_db(ctxs[0]) for _ in range(2)] == [want[0]] * 2
|
||||||
|
assert calls() == ["debug paths"]
|
||||||
|
assert adapter._resolve_db(ctxs[1]) == want[1]
|
||||||
|
assert adapter._resolve_db(ctxs[0]) == want[0]
|
||||||
|
assert calls() == ["debug paths"] * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_debug_paths_is_not_remembered(tmp_path, monkeypatch):
|
||||||
|
log = tmp_path / "calls.log"
|
||||||
|
failing = tmp_path / "fakebin" / "opencode"
|
||||||
|
failing.parent.mkdir()
|
||||||
|
failing.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexit 3\n')
|
||||||
|
failing.chmod(0o755)
|
||||||
|
adapter = get_adapter("opencode")
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing))
|
||||||
|
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(tmp_path / "home"))
|
||||||
|
default = str(tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db")
|
||||||
|
assert [adapter._resolve_db(ctx) for _ in range(2)] == [default] * 2
|
||||||
|
assert log.read_text().splitlines() == ["debug paths"] * 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. the stop's words match what happened
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SESSION = "i60-stop-opencode"
|
||||||
|
|
||||||
|
# A herdr in front of the conftest mock that, like a real opencode 2.x pane
|
||||||
|
# (Linux check 3-3), ends the agent on `/exit`: the prompt's wait for
|
||||||
|
# `working` then fails because the agent is gone.
|
||||||
|
EXITING_HERDR = """#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if "prompt" in args and "/exit" in args:
|
||||||
|
with open(@STATE@) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
state["agents"].pop(@NAME@, None)
|
||||||
|
state.setdefault("calls", []).append("herdr " + " ".join(args))
|
||||||
|
with open(@STATE@, "w") as f:
|
||||||
|
json.dump(state, f)
|
||||||
|
sys.stderr.write(json.dumps({"error": {"code": "agent_exited", "message": "agent exited"}}) + "\\n")
|
||||||
|
sys.exit(1)
|
||||||
|
os.execv(@MOCK@, [@MOCK@] + args)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exits", [True, False])
|
||||||
|
def test_the_graceful_stop_reports_what_happened(mam_sandbox, mock_herdr, mock_agents, monkeypatch, exits):
|
||||||
|
state = json.loads(Path(mock_herdr).read_text())
|
||||||
|
state["agents"][SESSION] = {"status": "running", "agent": "opencode", "agent_status": "idle",
|
||||||
|
"cwd": str(mam_sandbox), "pid": 12345, "pane_id": "w1:p1",
|
||||||
|
"command": "opencode", "buffer": "Ask anything"}
|
||||||
|
Path(mock_herdr).write_text(json.dumps(state, indent=2))
|
||||||
|
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
row = ("d['herdr_sessions'] = [{'name': '%s', 'agent': 'opencode', 'status': 'running', "
|
||||||
|
"'pane': {'cwd': '%s', 'pid': 12345, 'cmd': 'opencode'}}]" % (SESSION, mam_sandbox))
|
||||||
|
res = subprocess.run(["bash", "-c", f"source {lib} && atomic_dump_yaml {mam_sandbox / '.mam' / 'agent-sessions.yaml'}"],
|
||||||
|
input=row, capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
if exits:
|
||||||
|
wrap = mam_sandbox / "exitbin" / "herdr"
|
||||||
|
wrap.parent.mkdir()
|
||||||
|
wrap.write_text(EXITING_HERDR.replace("@STATE@", repr(str(mock_herdr))).replace("@NAME@", repr(SESSION))
|
||||||
|
.replace("@MOCK@", repr(shutil.which("herdr"))))
|
||||||
|
wrap.chmod(0o755)
|
||||||
|
monkeypatch.setenv("PATH", f"{wrap.parent}:{os.environ['PATH']}")
|
||||||
|
stop = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(stop), "--session", SESSION, "--agent", "opencode"],
|
||||||
|
capture_output=True, text=True, timeout=180)
|
||||||
|
out = res.stdout + res.stderr
|
||||||
|
assert res.returncode == 0, out
|
||||||
|
calls = json.loads(Path(mock_herdr).read_text()).get("calls", [])
|
||||||
|
assert any("prompt" in c and "/exit" in c for c in calls), calls
|
||||||
|
assert "falling back to kill chain" not in out
|
||||||
|
if exits:
|
||||||
|
assert "unconfirmed" in out and "graceful: exited cleanly" in out, out
|
||||||
|
assert "kill-session" not in out and not any("kill-session" in c for c in calls), out
|
||||||
|
else:
|
||||||
|
# The kill step runs, so it is named.
|
||||||
|
assert "still alive → kill-session" in out, out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. opencode's auth check is the binary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _executable(path: Path) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("#!/bin/sh\n")
|
||||||
|
path.chmod(0o755)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bare_home(tmp_path, monkeypatch):
|
||||||
|
"""An empty HOME_DIR and a PATH with no opencode."""
|
||||||
|
monkeypatch.setenv("HOME_DIR", str(tmp_path / "home"))
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
|
||||||
|
for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_keys_and_auth_json_without_the_binary_are_not_enough(bare_home, monkeypatch):
|
||||||
|
opencode = get_adapter("opencode")
|
||||||
|
assert opencode.auth_ok() is False
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||||
|
assert opencode.auth_ok() is False
|
||||||
|
auth = bare_home / "home" / ".local" / "share" / "opencode" / "auth.json"
|
||||||
|
auth.parent.mkdir(parents=True)
|
||||||
|
auth.write_text("{}")
|
||||||
|
assert opencode.auth_ok() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("where", ["path", "home"])
|
||||||
|
def test_the_binary_alone_is_enough(bare_home, monkeypatch, where):
|
||||||
|
if where == "path":
|
||||||
|
_executable(bare_home / "bin" / "opencode")
|
||||||
|
monkeypatch.setenv("PATH", str(bare_home / "bin"))
|
||||||
|
else:
|
||||||
|
_executable(bare_home / "home" / ".opencode" / "bin" / "opencode")
|
||||||
|
assert get_adapter("opencode").auth_ok() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_auth_ok_command_exits_with_the_verdict(bare_home):
|
||||||
|
def rc(*args):
|
||||||
|
env = dict(os.environ, PYTHONPATH=str(SKILLS))
|
||||||
|
return subprocess.run([sys.executable, "-m", "lib_py.agents", "auth-ok", *args],
|
||||||
|
capture_output=True, text=True, env=env).returncode
|
||||||
|
|
||||||
|
assert rc("opencode") == 1
|
||||||
|
_executable(bare_home / "home" / ".opencode" / "bin" / "opencode")
|
||||||
|
assert rc("opencode") == 0
|
||||||
|
assert rc("no-such-agent") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_decides_opencode_auth_without_running_it(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
||||||
|
# Every opencode call create makes is logged; --version was the old check.
|
||||||
|
log = mam_sandbox / "opencode-calls.log"
|
||||||
|
wrap = mam_sandbox / "logbin" / "opencode"
|
||||||
|
wrap.parent.mkdir()
|
||||||
|
wrap.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{shutil.which("opencode")}" "$@"\n')
|
||||||
|
wrap.chmod(0o755)
|
||||||
|
monkeypatch.setenv("PATH", f"{wrap.parent}:{os.environ['PATH']}")
|
||||||
|
for var in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
create = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(create), "--workspace", str(mam_sandbox), "--agent", "opencode",
|
||||||
|
"--role", "creator", "--no-onboard"],
|
||||||
|
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
|
||||||
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
calls = log.read_text().splitlines() if log.exists() else []
|
||||||
|
assert "--version" not in calls, calls
|
||||||
Reference in new issue
Block a user