Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4b96fa18 | ||
|
|
f0d1c000f9 | ||
|
|
a311c23303 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
import os, sys, sqlite3, shutil
|
import os, sys, sqlite3, shutil, subprocess
|
||||||
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
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
|
||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
@@ -59,56 +59,46 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
# blanks after the ┃) or the `Build auto · <model>` status line.
|
# blanks after the ┃) or the `Build auto · <model>` status line.
|
||||||
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
|
return r'^\s{20,}\S|^\s*(Build|Plan)\b.*·'
|
||||||
|
|
||||||
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
def _bin(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
# 1. Try opencode CLI "opencode db path"
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
bin_path = shutil.which("opencode")
|
bin_path = shutil.which("opencode")
|
||||||
if not bin_path:
|
if bin_path:
|
||||||
|
return bin_path
|
||||||
for candidate_bin in [
|
for candidate_bin in [
|
||||||
f"{ctx.home_dir}/.opencode/bin/opencode",
|
f"{ctx.home_dir}/.opencode/bin/opencode",
|
||||||
os.path.expanduser("~/.opencode/bin/opencode"),
|
os.path.expanduser("~/.opencode/bin/opencode"),
|
||||||
]:
|
]:
|
||||||
if os.path.exists(candidate_bin):
|
if os.path.exists(candidate_bin):
|
||||||
bin_path = candidate_bin
|
return candidate_bin
|
||||||
break
|
return None
|
||||||
if bin_path:
|
|
||||||
|
def _run(self, ctx: DiscoveryContext, args: list, timeout: int):
|
||||||
|
"""Run the opencode CLI against ctx's home (its data dir follows HOME and
|
||||||
|
XDG_DATA_HOME); None when there is no binary."""
|
||||||
|
bin_path = self._bin(ctx)
|
||||||
|
if not bin_path:
|
||||||
|
return None
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
if ctx.home_dir:
|
if ctx.home_dir:
|
||||||
env["HOME"] = ctx.home_dir
|
env["HOME"] = ctx.home_dir
|
||||||
env["HOME_DIR"] = ctx.home_dir
|
env["HOME_DIR"] = ctx.home_dir
|
||||||
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
|
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
|
||||||
cwd = ctx.workspace or ctx.cwd or os.getcwd()
|
cwd = ctx.workspace or ctx.cwd or os.getcwd()
|
||||||
res = subprocess.run(
|
return subprocess.run([bin_path] + args, capture_output=True, text=True, timeout=timeout,
|
||||||
[bin_path, "db", "path"],
|
env=env, cwd=cwd if os.path.exists(cwd) else None)
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
|
||||||
timeout=2,
|
# opencode 2.x prints its paths with `debug paths` (the 1.x `db path`
|
||||||
env=env,
|
# is gone: it prints the help and exits 0). Fallback: the default.
|
||||||
cwd=cwd if os.path.exists(cwd) else None
|
try:
|
||||||
)
|
res = self._run(ctx, ["debug", "paths"], timeout=5)
|
||||||
if res.returncode == 0:
|
if res is not None and res.returncode == 0:
|
||||||
out = res.stdout.strip()
|
for line in res.stdout.splitlines():
|
||||||
if out and os.path.exists(out):
|
parts = line.split(None, 1)
|
||||||
return out
|
if len(parts) == 2 and parts[0] == "db":
|
||||||
|
return parts[1].strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return f"{ctx.home_dir}/.local/share/opencode/opencode.db"
|
||||||
# 2. Filesystem fallbacks
|
|
||||||
base_dir = f"{ctx.home_dir}/.local/share/opencode"
|
|
||||||
candidates = [
|
|
||||||
f"{base_dir}/opencode.db",
|
|
||||||
f"{base_dir}/storage.db",
|
|
||||||
]
|
|
||||||
for cand in candidates:
|
|
||||||
if os.path.exists(cand):
|
|
||||||
return cand
|
|
||||||
if os.path.isdir(f"{base_dir}/project"):
|
|
||||||
for root, _, files in os.walk(f"{base_dir}/project"):
|
|
||||||
for f in files:
|
|
||||||
if f.endswith(".db"):
|
|
||||||
return os.path.join(root, f)
|
|
||||||
return candidates[0]
|
|
||||||
|
|
||||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||||
db = self._resolve_db(ctx)
|
db = self._resolve_db(ctx)
|
||||||
@@ -119,44 +109,18 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
if not db or not os.path.exists(db):
|
if not db or not os.path.exists(db):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
|
||||||
|
# survives a migration but is no longer written).
|
||||||
conn = sqlite3.connect(db)
|
conn = sqlite3.connect(db)
|
||||||
cursor = conn.cursor()
|
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
||||||
cursor.execute("PRAGMA table_info(session)")
|
|
||||||
cols = [col[1] for col in cursor.fetchall()]
|
|
||||||
if not cols:
|
|
||||||
conn.close()
|
|
||||||
return False
|
|
||||||
|
|
||||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
|
||||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
|
||||||
|
|
||||||
query_cols = []
|
|
||||||
if cwd_col:
|
|
||||||
query_cols.append(cwd_col)
|
|
||||||
if time_col:
|
|
||||||
query_cols.append(time_col)
|
|
||||||
query = "SELECT " + (", ".join(query_cols) if query_cols else "id") + " FROM session WHERE id=?"
|
|
||||||
r = cursor.execute(query, (uuid,)).fetchone()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
if not r:
|
if not r:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
found_cwd = None
|
found_cwd, started_at = r
|
||||||
started_at = None
|
|
||||||
idx = 0
|
|
||||||
if cwd_col:
|
|
||||||
found_cwd = r[idx]
|
|
||||||
idx += 1
|
|
||||||
if time_col:
|
|
||||||
started_at = r[idx]
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
if ctx.epoch and started_at is not None:
|
if ctx.epoch and started_at is not None:
|
||||||
try:
|
try:
|
||||||
val = float(started_at)
|
if float(started_at) / 1000.0 < float(ctx.epoch):
|
||||||
if time_col == "time_created" or val > 1e11:
|
|
||||||
val = val / 1000.0
|
|
||||||
if val < float(ctx.epoch):
|
|
||||||
return False
|
return False
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
@@ -169,7 +133,23 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
purged = []
|
purged = []
|
||||||
db = self._resolve_db(ctx)
|
db = self._resolve_db(ctx)
|
||||||
|
if db and os.path.exists(db) and self._has_session(db, uuid):
|
||||||
|
# opencode deletes a 2.x session with its child sessions and every
|
||||||
|
# row it keeps for them; --standalone so no background service is
|
||||||
|
# started for it.
|
||||||
|
try:
|
||||||
|
res = self._run(ctx, ["session", "delete", "--standalone", "--", uuid], timeout=60)
|
||||||
|
if res is None:
|
||||||
|
sys.stderr.write(f"WARN: purge opencode: no opencode binary to delete session {uuid}\n")
|
||||||
|
elif res.returncode == 0:
|
||||||
|
purged.append(f"opencode session (with its child sessions): {uuid}")
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} exited {res.returncode}: "
|
||||||
|
f"{(res.stderr or res.stdout).strip()}\n")
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"WARN: purge opencode: session delete {uuid} failed: {e}\n")
|
||||||
if db and os.path.exists(db):
|
if db and os.path.exists(db):
|
||||||
|
# Rows a 1.x opencode left in the tables a migration keeps.
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
conn = sqlite3.connect(db)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -189,25 +169,39 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
|
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
if tables & {"message", "messages", "part", "parts", "session", "sessions"}:
|
||||||
purged.append(f"sqlite rows for session: {uuid}")
|
purged.append(f"sqlite rows for session: {uuid}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
|
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
|
||||||
return purged
|
return purged
|
||||||
|
|
||||||
|
def _has_session(self, db: str, uuid: str) -> bool:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
try:
|
||||||
|
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
# Issue #31: the purge leaves event-sourced rows (event, todo, ...);
|
# Issue #31: whatever still names the session (event-sourced rows a
|
||||||
# deleting those is not known to be safe, so they are reported.
|
# 1.x opencode left, a failed `session delete`) is reported.
|
||||||
found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid)
|
found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid)
|
||||||
return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"),
|
return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"),
|
||||||
id_tables=(("session", "id"),))
|
id_tables=(("session", "id"), ("session_v2", "id")))
|
||||||
|
|
||||||
|
# opencode 2.x: the default agent is build (`--agent` is gone from the
|
||||||
|
# TUI), and --standalone keeps the server a child of the pane's TUI
|
||||||
|
# instead of the per-user background service.
|
||||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||||
return f"{binary} --auto --agent build"
|
return f"{binary} --auto --standalone"
|
||||||
|
|
||||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||||
if materialized and session_uuid:
|
if materialized and session_uuid:
|
||||||
return f"{binary} --session {session_uuid} --auto --agent build"
|
return f"{binary} --session {session_uuid} --auto --standalone"
|
||||||
return f"{binary} --auto --agent build"
|
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):
|
if any(os.environ.get(v) for v in _PROVIDER_ENV_VARS):
|
||||||
@@ -221,38 +215,20 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
if not db or not os.path.exists(db):
|
if not db or not os.path.exists(db):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
# Top-level sessions only: a subagent's session (parent_id set)
|
||||||
cursor = conn.cursor()
|
# shares the directory and is newer than the one the TUI shows.
|
||||||
cursor.execute("PRAGMA table_info(session)")
|
query = "SELECT id FROM session_v2 WHERE parent_id IS NULL"
|
||||||
cols = [col[1] for col in cursor.fetchall()]
|
|
||||||
if not cols:
|
|
||||||
conn.close()
|
|
||||||
return []
|
|
||||||
|
|
||||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
|
||||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
|
||||||
|
|
||||||
query = "SELECT id FROM session"
|
|
||||||
params = []
|
params = []
|
||||||
where_clauses = []
|
if ctx.workspace:
|
||||||
if cwd_col and ctx.workspace:
|
query += " AND directory = ?"
|
||||||
where_clauses.append(f"{cwd_col} = ?")
|
|
||||||
params.append(ctx.workspace)
|
params.append(ctx.workspace)
|
||||||
if time_col and ctx.epoch:
|
if ctx.epoch:
|
||||||
if time_col == "time_created":
|
query += " AND time_created >= ?"
|
||||||
where_clauses.append(f"{time_col} >= ?")
|
|
||||||
params.append(int(float(ctx.epoch) * 1000))
|
params.append(int(float(ctx.epoch) * 1000))
|
||||||
else:
|
query += " ORDER BY time_created DESC LIMIT 20"
|
||||||
where_clauses.append(f"{time_col} >= ?")
|
|
||||||
params.append(ctx.epoch)
|
|
||||||
|
|
||||||
if where_clauses:
|
conn = sqlite3.connect(db)
|
||||||
query += " WHERE " + " AND ".join(where_clauses)
|
rows = conn.execute(query, params).fetchall()
|
||||||
if time_col:
|
|
||||||
query += f" ORDER BY {time_col} DESC"
|
|
||||||
query += " LIMIT 20"
|
|
||||||
|
|
||||||
rows = cursor.execute(query, params).fetchall()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
candidates = []
|
candidates = []
|
||||||
for (cand,) in rows:
|
for (cand,) in rows:
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ if [ -z "$CMD_FULL" ]; then
|
|||||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
||||||
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
||||||
grok) CMD_FULL="${RESOLVED_BIN} --permission-mode bypassPermissions" ;;
|
grok) CMD_FULL="${RESOLVED_BIN} --permission-mode bypassPermissions" ;;
|
||||||
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
|
opencode) CMD_FULL="${RESOLVED_BIN} --auto --standalone" ;;
|
||||||
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -952,15 +952,20 @@ for s in d.get('herdr_sessions', []):
|
|||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(odb)
|
conn = sqlite3.connect(odb)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("PRAGMA table_info(session)")
|
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
|
||||||
|
# table is no longer written).
|
||||||
|
cursor.execute("PRAGMA table_info(session_v2)")
|
||||||
cols = [col[1] for col in cursor.fetchall()]
|
cols = [col[1] for col in cursor.fetchall()]
|
||||||
if cols:
|
if cols:
|
||||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
||||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
||||||
|
|
||||||
query = "SELECT id FROM session"
|
query = "SELECT id FROM session_v2"
|
||||||
params = []
|
params = []
|
||||||
where_clauses = []
|
where_clauses = []
|
||||||
|
if "parent_id" in cols:
|
||||||
|
# a subagent's session shares the directory and is newer
|
||||||
|
where_clauses.append("parent_id IS NULL")
|
||||||
if cwd_col and cwd:
|
if cwd_col and cwd:
|
||||||
where_clauses.append(f"{cwd_col} = ?")
|
where_clauses.append(f"{cwd_col} = ?")
|
||||||
params.append(cwd)
|
params.append(cwd)
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ if [ "$FRESH_SPAWN" = "1" ]; then
|
|||||||
case "$AGENT" in
|
case "$AGENT" in
|
||||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
||||||
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
||||||
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
|
opencode) CMD_FULL="${RESOLVED_BIN} --auto --standalone" ;;
|
||||||
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
@@ -190,7 +190,7 @@ else
|
|||||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
||||||
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;;
|
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;;
|
||||||
grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;;
|
grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;;
|
||||||
opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;;
|
opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --standalone" ;;
|
||||||
codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;
|
codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;
|
||||||
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -238,10 +238,10 @@ elif agent == 'grok':
|
|||||||
elif agent == 'opencode':
|
elif agent == 'opencode':
|
||||||
target['pane']['cmd'] = 'opencode'
|
target['pane']['cmd'] = 'opencode'
|
||||||
if uuid:
|
if uuid:
|
||||||
target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --agent build'
|
target['pane']['cmd_full'] = f'opencode --session {uuid} --auto --standalone'
|
||||||
target['opencode_session_id_own'] = uuid
|
target['opencode_session_id_own'] = uuid
|
||||||
else:
|
else:
|
||||||
target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --agent build'
|
target['pane']['cmd_full'] = os.environ.get('CMD_FULL_OPT', '') or 'opencode --auto --standalone'
|
||||||
cp = os.environ.get('CHILD_PID', '0')
|
cp = os.environ.get('CHILD_PID', '0')
|
||||||
if cp.isdigit() and int(cp) > 0:
|
if cp.isdigit() and int(cp) > 0:
|
||||||
target['child_pid'] = int(cp)
|
target['child_pid'] = int(cp)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ def resume_on_disk(s):
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
conn = sqlite3.connect(odb)
|
conn = sqlite3.connect(odb)
|
||||||
r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone()
|
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
||||||
conn.close()
|
conn.close()
|
||||||
return 'yes' if r else 'MISSING'
|
return 'yes' if r else 'MISSING'
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -334,7 +334,7 @@ def resume_on_disk(s):
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
conn = sqlite3.connect(odb)
|
conn = sqlite3.connect(odb)
|
||||||
r = conn.execute("SELECT 1 FROM session WHERE id=?", (u,)).fetchone()
|
r = conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (u,)).fetchone() # opencode 2.x
|
||||||
conn.close()
|
conn.close()
|
||||||
return 'yes' if r else 'MISSING'
|
return 'yes' if r else 'MISSING'
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ def test_adapter_required_properties():
|
|||||||
|
|
||||||
| 에이전트 백엔드 | 세션 저장소 형식 | 인증 방식 | 프롬프트 감지 난이도 | 난이도 Tier | 예상 소요 공수 |
|
| 에이전트 백엔드 | 세션 저장소 형식 | 인증 방식 | 프롬프트 감지 난이도 | 난이도 Tier | 예상 소요 공수 |
|
||||||
| :--- | :--- | :--- | :--- | :---: | :---: |
|
| :--- | :--- | :--- | :--- | :---: | :---: |
|
||||||
| **OpenCode** | `~/.local/share/opencode/` (SQLite, `opencode db path`) | 멀티 Provider Key / `auth.json` | 쉬움 (`OpenCode\|Chat`) | **Tier 1 (낮음)** | **~1.0일** |
|
| **OpenCode** (2.x 전용, Issue #60) | `~/.local/share/opencode/opencode.db` (SQLite `session_v2`, 경로는 `opencode debug paths`) | 멀티 Provider Key (DB `credential`) / 무료 모델 | 쉬움 (`OpenCode\|Chat`) | **Tier 1 (낮음)** | **~1.0일** |
|
||||||
| **Codex CLI** | `~/.codex/sessions/*/*/*/rollout-*.jsonl` (SQLite `state_*.sqlite`) | `auth.json` / `OPENAI_API_KEY` | 쉬움 (`Codex\|Ask Codex to do anything`) | **v4.2.0 완료** | **완료** |
|
| **Codex CLI** | `~/.codex/sessions/*/*/*/rollout-*.jsonl` (SQLite `state_*.sqlite`) | `auth.json` / `OPENAI_API_KEY` | 쉬움 (`Codex\|Ask Codex to do anything`) | **v4.2.0 완료** | **완료** |
|
||||||
| **Kimi CLI** | `~/.kimi/history.db` (SQLite) | `~/.kimi/config` | 쉬움 (`Moonshot\|Kimi`) | **Tier 1 (낮음)** | **~0.5일** |
|
| **Kimi CLI** | `~/.kimi/history.db` (SQLite) | `~/.kimi/config` | 쉬움 (`Moonshot\|Kimi`) | **Tier 1 (낮음)** | **~0.5일** |
|
||||||
| **Grok-Build** | `~/.grok/sessions/*.json` | 토큰 파일 / Env | 쉬움 (`Grok\|Building`) | **Tier 1 (낮음)** | **~0.5일** |
|
| **Grok-Build** | `~/.grok/sessions/*.json` | 토큰 파일 / Env | 쉬움 (`Grok\|Building`) | **Tier 1 (낮음)** | **~0.5일** |
|
||||||
|
|||||||
@@ -93,10 +93,8 @@ opencode
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 CLI 플래그로 특정 모델 즉시 실행 (`-m`)
|
### 4.2 CLI 플래그로 특정 모델 즉시 실행 (`-m`)
|
||||||
|
opencode 2.x 의 대화형 TUI(`opencode`)는 `-m`/`--model`·`--agent` 를 받지 않습니다(`Unrecognized flag`). TUI 의 모델은 설정 파일의 `"model"` 로 정하고, `-m` 은 비대화형 `run` 에서만 씁니다.
|
||||||
```bash
|
```bash
|
||||||
# 대화형 TUI 실행
|
|
||||||
opencode -m "ollama/qwen2.5-coder:32b"
|
|
||||||
|
|
||||||
# 비대화형(Headless) 무인 단발 태스크 실행
|
# 비대화형(Headless) 무인 단발 태스크 실행
|
||||||
opencode run -m "ollama/qwen2.5-coder:32b" "tests/ 단위 테스트를 실행하고 실패하는 테스트를 고쳐줘"
|
opencode run -m "ollama/qwen2.5-coder:32b" "tests/ 단위 테스트를 실행하고 실패하는 테스트를 고쳐줘"
|
||||||
```
|
```
|
||||||
@@ -108,7 +106,7 @@ opencode run -m "ollama/qwen2.5-coder:32b" "tests/ 단위 테스트를 실행하
|
|||||||
|
|
||||||
## 5. Multi-Agent Mux (MAM) 연동 예시
|
## 5. Multi-Agent Mux (MAM) 연동 예시
|
||||||
|
|
||||||
MAM 환경에서 OpenCode를 reviewer/creator 에이전트로 띄울 때의 표준 명령어:
|
MAM 환경에서 OpenCode를 reviewer/creator 에이전트로 띄울 때의 표준 명령어입니다. MAM 은 **opencode 2.x 만** 지원하며(Issue #60), pane 에서 `opencode --auto --standalone`(재개는 `--session <id>` 추가)으로 띄웁니다. `--standalone` 이라 서버가 TUI 의 자식으로 떠서 세션을 닫으면 함께 끝나고, 사용자별 백그라운드 서비스는 쓰지도 띄우지도 않습니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 세션 생성 및 온보딩
|
# 1. 세션 생성 및 온보딩
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""A fake opencode 2.x CLI for tests (Issue #60): only what MAM calls.
|
||||||
|
|
||||||
|
- `debug paths` prints the `data` and `db` lines of the real command (the db
|
||||||
|
follows XDG_DATA_HOME, else HOME/.local/share; the database is not created);
|
||||||
|
- `session delete [--standalone] [--] <id>` deletes the session and its child
|
||||||
|
sessions from the 2.x tables (session_v2, session_message, session_inbox,
|
||||||
|
session_pending, event, event_sequence) and leaves the tables a 1.x
|
||||||
|
migration keeps (session, message, part, todo, session_share), as the real
|
||||||
|
2.0.32 command was measured to do; prints `Session <id> deleted`, or
|
||||||
|
`Session not found: <id>` with exit 1.
|
||||||
|
|
||||||
|
Tests never run a real opencode: a real one would migrate the test's
|
||||||
|
database. The contract against a real binary is checked by the opt-in test in
|
||||||
|
tests/test_issue60_opencode_v2.py.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
SCRIPT = r'''#!/usr/bin/env python3
|
||||||
|
import os, sqlite3, sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
data = os.environ.get("XDG_DATA_HOME") or os.path.join(os.environ.get("HOME", ""), ".local", "share")
|
||||||
|
db = os.path.join(data, "opencode", "opencode.db")
|
||||||
|
if args == ["debug", "paths"]:
|
||||||
|
print("data " + os.path.dirname(db))
|
||||||
|
print("db " + db)
|
||||||
|
sys.exit(0)
|
||||||
|
if args[:2] == ["session", "delete"]:
|
||||||
|
sid = args[-1]
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
try:
|
||||||
|
if not conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (sid,)).fetchone():
|
||||||
|
print("Session not found: " + sid, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
ids, todo = [], [sid]
|
||||||
|
while todo:
|
||||||
|
cur = todo.pop()
|
||||||
|
ids.append(cur)
|
||||||
|
todo += [r[0] for r in conn.execute("SELECT id FROM session_v2 WHERE parent_id=?", (cur,))]
|
||||||
|
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
v2 = (("session_message", "session_id"), ("session_inbox", "session_id"),
|
||||||
|
("session_pending", "session_id"), ("event", "aggregate_id"),
|
||||||
|
("event_sequence", "aggregate_id"), ("session_v2", "id"))
|
||||||
|
for t, col in v2:
|
||||||
|
if t in tables:
|
||||||
|
for i in ids:
|
||||||
|
conn.execute('DELETE FROM "%s" WHERE "%s"=?' % (t, col), (i,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
print("Session " + sid + " deleted")
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def write(path) -> str:
|
||||||
|
"""Write the fake as an executable `path` and return it as a str."""
|
||||||
|
path = str(path)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(SCRIPT)
|
||||||
|
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC)
|
||||||
|
return path
|
||||||
+5
-21
@@ -131,6 +131,7 @@ if _TESTS_DIR not in sys.path:
|
|||||||
sys.path.insert(0, _TESTS_DIR)
|
sys.path.insert(0, _TESTS_DIR)
|
||||||
import _mam_state_guard # noqa: E402
|
import _mam_state_guard # noqa: E402
|
||||||
import _agent_home_guard # noqa: E402
|
import _agent_home_guard # noqa: E402
|
||||||
|
import _fake_opencode # noqa: E402
|
||||||
|
|
||||||
_BINDING_VARS = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
|
_BINDING_VARS = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
|
||||||
_RECORDED_BINDING = {}
|
_RECORDED_BINDING = {}
|
||||||
@@ -971,9 +972,9 @@ elif cmd1 == "agent":
|
|||||||
os.makedirs(opencode_dir, exist_ok=True)
|
os.makedirs(opencode_dir, exist_ok=True)
|
||||||
odb = os.path.join(opencode_dir, "opencode.db")
|
odb = os.path.join(opencode_dir, "opencode.db")
|
||||||
conn = sqlite3.connect(odb)
|
conn = sqlite3.connect(odb)
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER, cwd TEXT, created_at REAL)")
|
conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)")
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
conn.execute("INSERT OR REPLACE INTO session (id, directory, time_created, cwd, created_at) VALUES (?, ?, ?, ?, ?)", (session_uuid, ws_abs, now_ms, ws_abs, time.time()))
|
conn.execute("INSERT OR REPLACE INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)", (session_uuid, ws_abs, now_ms))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
elif agent_type == "codex":
|
elif agent_type == "codex":
|
||||||
@@ -1466,25 +1467,8 @@ sys.exit(0)
|
|||||||
""")
|
""")
|
||||||
grok_bin.chmod(0o755)
|
grok_bin.chmod(0o755)
|
||||||
|
|
||||||
# 5. opencode mock
|
# 5. opencode mock (the 2.x commands MAM calls, Issue #60)
|
||||||
opencode_bin = bin_dir / "opencode"
|
_fake_opencode.write(bin_dir / "opencode")
|
||||||
opencode_bin.write_text("""#!/usr/bin/env python3
|
|
||||||
import sys, os
|
|
||||||
args = sys.argv[1:]
|
|
||||||
if len(args) >= 2 and args[0] == "db" and args[1] == "path":
|
|
||||||
home = os.environ.get("HOME", "")
|
|
||||||
db_path = f"{home}/.local/share/opencode/opencode.db"
|
|
||||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER)")
|
|
||||||
conn.close()
|
|
||||||
print(db_path)
|
|
||||||
sys.exit(0)
|
|
||||||
sys.exit(0)
|
|
||||||
""")
|
|
||||||
opencode_bin.chmod(0o755)
|
|
||||||
|
|
||||||
# 6. codex mock
|
# 6. codex mock
|
||||||
codex_bin = bin_dir / "codex"
|
codex_bin = bin_dir / "codex"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import os, pytest
|
import os, pytest
|
||||||
from lib_py.paths import resolve_home
|
from lib_py.paths import resolve_home
|
||||||
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
||||||
|
import _fake_opencode # tests/ is on sys.path (conftest)
|
||||||
|
|
||||||
def test_resolve_home_contract():
|
def test_resolve_home_contract():
|
||||||
# 1. When explicit home_dir is passed
|
# 1. When explicit home_dir is passed
|
||||||
@@ -132,73 +133,19 @@ def shlex_quote(s):
|
|||||||
import shlex
|
import shlex
|
||||||
return shlex.quote(s)
|
return shlex.quote(s)
|
||||||
|
|
||||||
def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec):
|
def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec, parent_id=None):
|
||||||
|
"""A session row of the opencode 2.x schema (session_v2, Issue #60)."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
cursor = conn.cursor()
|
conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)")
|
||||||
cursor.execute("PRAGMA foreign_keys = OFF")
|
conn.execute("INSERT OR REPLACE INTO session_v2 (id, parent_id, directory, time_created) VALUES (?, ?, ?, ?)",
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
(session_id, parent_id, ws_path, int(float(epoch_sec) * 1000)))
|
||||||
tables = {row[0] for row in cursor.fetchall()}
|
|
||||||
if "session" not in tables:
|
|
||||||
conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER, cwd TEXT, created_at REAL)")
|
|
||||||
if "project" in tables:
|
|
||||||
cursor.execute("PRAGMA table_info(project)")
|
|
||||||
p_cols = [col[1] for col in cursor.fetchall()]
|
|
||||||
p_ins = ["id"]
|
|
||||||
p_vals = ["default"]
|
|
||||||
if "worktree" in p_cols:
|
|
||||||
p_ins.append("worktree")
|
|
||||||
p_vals.append(ws_path)
|
|
||||||
if "time_created" in p_cols:
|
|
||||||
p_ins.append("time_created")
|
|
||||||
p_vals.append(0)
|
|
||||||
if "time_updated" in p_cols:
|
|
||||||
p_ins.append("time_updated")
|
|
||||||
p_vals.append(0)
|
|
||||||
conn.execute(f"INSERT OR IGNORE INTO project ({', '.join(p_ins)}) VALUES ({', '.join(['?']*len(p_vals))})", p_vals)
|
|
||||||
|
|
||||||
cursor.execute("PRAGMA table_info(session)")
|
|
||||||
cols = [col[1] for col in cursor.fetchall()]
|
|
||||||
|
|
||||||
insert_cols = ["id"]
|
|
||||||
vals = [session_id]
|
|
||||||
if "project_id" in cols:
|
|
||||||
insert_cols.append("project_id")
|
|
||||||
vals.append("default")
|
|
||||||
if "slug" in cols:
|
|
||||||
insert_cols.append("slug")
|
|
||||||
vals.append("default")
|
|
||||||
if "title" in cols:
|
|
||||||
insert_cols.append("title")
|
|
||||||
vals.append("default")
|
|
||||||
if "version" in cols:
|
|
||||||
insert_cols.append("version")
|
|
||||||
vals.append("1.0.0")
|
|
||||||
if "directory" in cols:
|
|
||||||
insert_cols.append("directory")
|
|
||||||
vals.append(ws_path)
|
|
||||||
if "cwd" in cols:
|
|
||||||
insert_cols.append("cwd")
|
|
||||||
vals.append(ws_path)
|
|
||||||
if "time_created" in cols:
|
|
||||||
insert_cols.append("time_created")
|
|
||||||
vals.append(int(float(epoch_sec) * 1000))
|
|
||||||
if "time_updated" in cols:
|
|
||||||
insert_cols.append("time_updated")
|
|
||||||
vals.append(int(float(epoch_sec) * 1000))
|
|
||||||
if "created_at" in cols:
|
|
||||||
insert_cols.append("created_at")
|
|
||||||
vals.append(float(epoch_sec))
|
|
||||||
|
|
||||||
placeholders = ", ".join(["?"] * len(vals))
|
|
||||||
col_str = ", ".join(insert_cols)
|
|
||||||
conn.execute(f"INSERT OR REPLACE INTO session ({col_str}) VALUES ({placeholders})", vals)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def test_purge_artifacts_composite(tmp_path):
|
def test_purge_artifacts_composite(tmp_path, monkeypatch):
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from lib_py.agents.base import DiscoveryContext
|
from lib_py.agents.base import DiscoveryContext
|
||||||
ws = str(tmp_path / "ws")
|
ws = str(tmp_path / "ws")
|
||||||
@@ -272,60 +219,31 @@ def test_purge_artifacts_composite(tmp_path):
|
|||||||
assert not os.path.exists(g_path)
|
assert not os.path.exists(g_path)
|
||||||
assert not os.path.exists(os.path.dirname(g_path))
|
assert not os.path.exists(os.path.dirname(g_path))
|
||||||
|
|
||||||
# 5. OpenCode (SQLite tables session, message, part)
|
# 5. OpenCode 2.x: `opencode session delete` removes the session (the
|
||||||
|
# fake opencode, as the real one does), and the rows a 1.x opencode left
|
||||||
|
# behind (message, part) are deleted directly (Issue #60).
|
||||||
opencode_adapter = get_adapter('opencode')
|
opencode_adapter = get_adapter('opencode')
|
||||||
|
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode")
|
||||||
|
monkeypatch.setattr(opencode_adapter, "_bin", lambda ctx: fake)
|
||||||
opencode_ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
|
opencode_ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
|
||||||
o_db = opencode_adapter.artifact_path('uuid-o', opencode_ctx)
|
o_db = opencode_adapter.artifact_path('uuid-o', opencode_ctx)
|
||||||
|
assert o_db == f"{home}/.local/share/opencode/opencode.db"
|
||||||
_seed_opencode_session(o_db, 'uuid-o', ws, 2000.0)
|
_seed_opencode_session(o_db, 'uuid-o', ws, 2000.0)
|
||||||
conn = sqlite3.connect(o_db)
|
conn = sqlite3.connect(o_db)
|
||||||
cursor = conn.cursor()
|
conn.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||||
cursor.execute("PRAGMA foreign_keys = OFF")
|
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
||||||
tables = {row[0] for row in cursor.fetchall()}
|
|
||||||
if "message" not in tables:
|
|
||||||
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
|
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||||
if "part" not in tables:
|
|
||||||
conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)")
|
conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||||
|
conn.execute("INSERT INTO session_message VALUES ('sm1', 'uuid-o')")
|
||||||
cursor.execute("PRAGMA table_info(message)")
|
conn.execute("INSERT INTO message VALUES ('m1', 'uuid-o')")
|
||||||
m_cols = [c[1] for c in cursor.fetchall()]
|
conn.execute("INSERT INTO part VALUES ('p1', 'uuid-o')")
|
||||||
m_ins = ["id", "session_id"] if "id" in m_cols else ["session_id"]
|
|
||||||
m_vals = ["m1", "uuid-o"] if "id" in m_cols else ["uuid-o"]
|
|
||||||
if "time_created" in m_cols:
|
|
||||||
m_ins.append("time_created")
|
|
||||||
m_vals.append(0)
|
|
||||||
if "time_updated" in m_cols:
|
|
||||||
m_ins.append("time_updated")
|
|
||||||
m_vals.append(0)
|
|
||||||
if "data" in m_cols:
|
|
||||||
m_ins.append("data")
|
|
||||||
m_vals.append("{}")
|
|
||||||
conn.execute(f"INSERT OR REPLACE INTO message ({', '.join(m_ins)}) VALUES ({', '.join(['?']*len(m_vals))})", m_vals)
|
|
||||||
|
|
||||||
cursor.execute("PRAGMA table_info(part)")
|
|
||||||
p_cols = [c[1] for c in cursor.fetchall()]
|
|
||||||
p_ins = ["id", "session_id"] if "id" in p_cols else ["session_id"]
|
|
||||||
p_vals = ["p1", "uuid-o"] if "id" in p_cols else ["uuid-o"]
|
|
||||||
if "message_id" in p_cols:
|
|
||||||
p_ins.append("message_id")
|
|
||||||
p_vals.append("m1")
|
|
||||||
if "time_created" in p_cols:
|
|
||||||
p_ins.append("time_created")
|
|
||||||
p_vals.append(0)
|
|
||||||
if "time_updated" in p_cols:
|
|
||||||
p_ins.append("time_updated")
|
|
||||||
p_vals.append(0)
|
|
||||||
if "data" in p_cols:
|
|
||||||
p_ins.append("data")
|
|
||||||
p_vals.append("{}")
|
|
||||||
conn.execute(f"INSERT OR REPLACE INTO part ({', '.join(p_ins)}) VALUES ({', '.join(['?']*len(p_vals))})", p_vals)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
purged_o = opencode_adapter.purge_artifacts('uuid-o', opencode_ctx)
|
purged_o = opencode_adapter.purge_artifacts('uuid-o', opencode_ctx)
|
||||||
assert len(purged_o) == 1
|
assert purged_o == ["opencode session (with its child sessions): uuid-o", "sqlite rows for session: uuid-o"]
|
||||||
conn = sqlite3.connect(o_db)
|
conn = sqlite3.connect(o_db)
|
||||||
assert conn.execute("SELECT count(*) FROM session WHERE id='uuid-o'").fetchone()[0] == 0
|
assert conn.execute("SELECT count(*) FROM session_v2 WHERE id='uuid-o'").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT count(*) FROM session_message WHERE session_id='uuid-o'").fetchone()[0] == 0
|
||||||
assert conn.execute("SELECT count(*) FROM message WHERE session_id='uuid-o'").fetchone()[0] == 0
|
assert conn.execute("SELECT count(*) FROM message WHERE session_id='uuid-o'").fetchone()[0] == 0
|
||||||
assert conn.execute("SELECT count(*) FROM part WHERE session_id='uuid-o'").fetchone()[0] == 0
|
assert conn.execute("SELECT count(*) FROM part WHERE session_id='uuid-o'").fetchone()[0] == 0
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -353,10 +271,10 @@ def test_adapter_spawn_and_resume_specs():
|
|||||||
assert grok.resume_spec('grok', 'u1', materialized=False) == 'grok --session-id u1 --permission-mode bypassPermissions'
|
assert grok.resume_spec('grok', 'u1', materialized=False) == 'grok --session-id u1 --permission-mode bypassPermissions'
|
||||||
|
|
||||||
opencode = get_adapter('opencode')
|
opencode = get_adapter('opencode')
|
||||||
assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --agent build'
|
assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --standalone'
|
||||||
assert opencode.spawn_spec('opencode', '') == 'opencode --auto --agent build'
|
assert opencode.spawn_spec('opencode', '') == 'opencode --auto --standalone'
|
||||||
assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --agent build'
|
assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --standalone'
|
||||||
assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --agent build'
|
assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --standalone'
|
||||||
|
|
||||||
codex = get_adapter('codex')
|
codex = get_adapter('codex')
|
||||||
assert codex.spawn_spec('codex', 'u1') == 'codex --dangerously-bypass-approvals-and-sandbox'
|
assert codex.spawn_spec('codex', 'u1') == 'codex --dangerously-bypass-approvals-and-sandbox'
|
||||||
@@ -821,12 +739,13 @@ def test_opencode_real_schema_directory_and_time_created_ms(tmp_path):
|
|||||||
odb = f"{home}/.local/share/opencode/opencode.db"
|
odb = f"{home}/.local/share/opencode/opencode.db"
|
||||||
os.makedirs(os.path.dirname(odb), exist_ok=True)
|
os.makedirs(os.path.dirname(odb), exist_ok=True)
|
||||||
conn = sqlite3.connect(odb)
|
conn = sqlite3.connect(odb)
|
||||||
conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL)")
|
conn.execute("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)")
|
||||||
|
|
||||||
epoch_floor_sec = 1724920000
|
epoch_floor_sec = 1724920000
|
||||||
conn.execute("INSERT INTO session VALUES ('u-target-fresh', ?, ?)", (ws1, int((epoch_floor_sec + 100) * 1000)))
|
ins = "INSERT INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)"
|
||||||
conn.execute("INSERT INTO session VALUES ('u-target-old', ?, ?)", (ws1, int((epoch_floor_sec - 100) * 1000)))
|
conn.execute(ins, ('u-target-fresh', ws1, int((epoch_floor_sec + 100) * 1000)))
|
||||||
conn.execute("INSERT INTO session VALUES ('u-foreign', ?, ?)", (ws2, int((epoch_floor_sec + 200) * 1000)))
|
conn.execute(ins, ('u-target-old', ws1, int((epoch_floor_sec - 100) * 1000)))
|
||||||
|
conn.execute(ins, ('u-foreign', ws2, int((epoch_floor_sec + 200) * 1000)))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -844,7 +763,8 @@ def test_opencode_real_schema_directory_and_time_created_ms(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch):
|
def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch):
|
||||||
"""Verify _resolve_db correctly executes opencode db path and falls back to candidate paths."""
|
"""_resolve_db reads the `db` line of `opencode debug paths` (2.x; the 1.x
|
||||||
|
`db path` only prints the help) and falls back to the default path."""
|
||||||
import stat
|
import stat
|
||||||
from lib_py.agents.registry import get_adapter
|
from lib_py.agents.registry import get_adapter
|
||||||
from lib_py.agents.base import DiscoveryContext
|
from lib_py.agents.base import DiscoveryContext
|
||||||
@@ -855,18 +775,15 @@ def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch):
|
|||||||
os.makedirs(home, exist_ok=True)
|
os.makedirs(home, exist_ok=True)
|
||||||
os.makedirs(ws, exist_ok=True)
|
os.makedirs(ws, exist_ok=True)
|
||||||
|
|
||||||
expected_db = f"{home}/.local/share/opencode/custom_opencode.db"
|
expected_db = f"{tmp_path}/elsewhere/opencode/opencode.db"
|
||||||
os.makedirs(os.path.dirname(expected_db), exist_ok=True)
|
|
||||||
with open(expected_db, "w") as f:
|
|
||||||
f.write("mock db")
|
|
||||||
|
|
||||||
# Create mock opencode binary that outputs custom DB path
|
|
||||||
bin_dir = tmp_path / "bin"
|
bin_dir = tmp_path / "bin"
|
||||||
os.makedirs(bin_dir, exist_ok=True)
|
os.makedirs(bin_dir, exist_ok=True)
|
||||||
mock_bin = bin_dir / "opencode"
|
mock_bin = bin_dir / "opencode"
|
||||||
mock_bin.write_text(f"""#!/bin/sh
|
mock_bin.write_text(f"""#!/bin/sh
|
||||||
if [ "$1" = "db" ] && [ "$2" = "path" ]; then
|
if [ "$1" = "debug" ] && [ "$2" = "paths" ]; then
|
||||||
echo "{expected_db}"
|
echo "home {home}"
|
||||||
|
echo "data {tmp_path}/elsewhere/opencode"
|
||||||
|
echo "db {expected_db}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
@@ -877,6 +794,9 @@ exit 1
|
|||||||
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
|
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
|
||||||
assert adapter._resolve_db(ctx) == expected_db
|
assert adapter._resolve_db(ctx) == expected_db
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
||||||
|
assert adapter._resolve_db(ctx) == f"{home}/.local/share/opencode/opencode.db"
|
||||||
|
|
||||||
|
|
||||||
def _seed_codex_session(home_dir, session_id, ws_path, epoch_sec):
|
def _seed_codex_session(home_dir, session_id, ws_path, epoch_sec):
|
||||||
import sqlite3, json, time
|
import sqlite3, json, time
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ sys.path.insert(0, str(SKILLS))
|
|||||||
from lib_py.agents.registry import get_adapter # noqa: E402
|
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||||
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
||||||
from lib_py.verify_session import workspace_key # noqa: E402
|
from lib_py.verify_session import workspace_key # noqa: E402
|
||||||
|
import _fake_opencode # noqa: E402 (tests/ is on sys.path via conftest)
|
||||||
|
|
||||||
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
|
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
|
||||||
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
|
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
|
||||||
@@ -896,9 +897,14 @@ def _db(path: Path, ddl: list, rows: list):
|
|||||||
|
|
||||||
|
|
||||||
def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
||||||
|
# A database migrated from 1.x (Issue #60): `opencode session delete`
|
||||||
|
# (the fake, as measured on 2.0.32) removes the 2.x rows, the purge
|
||||||
|
# deletes the 1.x session/message/part rows, and todo is reported.
|
||||||
u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}"
|
u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}"
|
||||||
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
||||||
ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)",
|
ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)",
|
||||||
|
"CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT)",
|
||||||
|
"CREATE TABLE session_message (id TEXT, session_id TEXT)",
|
||||||
"CREATE TABLE message (id TEXT, session_id TEXT)",
|
"CREATE TABLE message (id TEXT, session_id TEXT)",
|
||||||
"CREATE TABLE part (id TEXT, session_id TEXT)",
|
"CREATE TABLE part (id TEXT, session_id TEXT)",
|
||||||
"CREATE TABLE event (id TEXT, aggregate_id TEXT)",
|
"CREATE TABLE event (id TEXT, aggregate_id TEXT)",
|
||||||
@@ -907,6 +913,8 @@ def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
|||||||
rows = []
|
rows = []
|
||||||
for s in (u, o):
|
for s in (u, o):
|
||||||
rows += [("INSERT INTO session VALUES (?)", (s,)),
|
rows += [("INSERT INTO session VALUES (?)", (s,)),
|
||||||
|
("INSERT INTO session_v2 VALUES (?, NULL)", (s,)),
|
||||||
|
("INSERT INTO session_message VALUES ('sm', ?)", (s,)),
|
||||||
("INSERT INTO message VALUES ('m', ?)", (s,)),
|
("INSERT INTO message VALUES ('m', ?)", (s,)),
|
||||||
("INSERT INTO part VALUES ('p', ?)", (s,)),
|
("INSERT INTO part VALUES ('p', ?)", (s,)),
|
||||||
("INSERT INTO event VALUES ('e1', ?)", (s,)),
|
("INSERT INTO event VALUES ('e1', ?)", (s,)),
|
||||||
@@ -915,14 +923,16 @@ def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
|
|||||||
("INSERT INTO todo VALUES ('t', ?)", (s,))]
|
("INSERT INTO todo VALUES ('t', ?)", (s,))]
|
||||||
_db(db, ddl, rows)
|
_db(db, ddl, rows)
|
||||||
adapter = get_adapter("opencode")
|
adapter = get_adapter("opencode")
|
||||||
monkeypatch.setattr(adapter, "_resolve_db", lambda ctx: str(db)) # never run a real opencode
|
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode")
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: fake) # never run a real opencode
|
||||||
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(home))
|
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(home))
|
||||||
|
assert f"{db} (session_v2.id: 1 rows)" in adapter.residual_artifacts(u, ctx)
|
||||||
|
|
||||||
adapter.purge_artifacts(u, ctx)
|
assert adapter.purge_artifacts(u, ctx) == [f"opencode session (with its child sessions): {u}",
|
||||||
|
f"sqlite rows for session: {u}"]
|
||||||
|
|
||||||
assert adapter.residual_artifacts(u, ctx) == [f"{db} (event.aggregate_id: 2 rows)",
|
assert adapter.residual_artifacts(u, ctx) == [f"{db} (todo.session_id: 1 rows)"]
|
||||||
f"{db} (event_sequence.aggregate_id: 1 rows)",
|
assert len(adapter.residual_artifacts(o, ctx)) == 8 # the other session is untouched: one entry per table
|
||||||
f"{db} (todo.session_id: 1 rows)"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other):
|
def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other):
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Issue #60: opencode 2.x.
|
||||||
|
|
||||||
|
Measured on opencode 2.0.32 (macOS): the TUI rejects `--agent`, the database
|
||||||
|
keeps sessions in session_v2 (a fresh 2.x database has no `session` table; a
|
||||||
|
migrated one keeps the 1.x tables but no longer writes them), `db path` is
|
||||||
|
gone (`debug paths` prints the db), and `session delete` removes a session
|
||||||
|
with its child sessions from the 2.x tables only.
|
||||||
|
|
||||||
|
The unit tests use the fake opencode of tests/_fake_opencode.py. The last
|
||||||
|
test runs a real opencode 2.x, when one is installed, in a home and tmp dir
|
||||||
|
of its own with --standalone (no background service is started or reached).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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"
|
||||||
|
sys.path.insert(0, str(SKILLS))
|
||||||
|
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||||
|
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
||||||
|
import _fake_opencode # noqa: E402
|
||||||
|
|
||||||
|
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)",
|
||||||
|
"CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oc(tmp_path, monkeypatch):
|
||||||
|
"""The opencode adapter on a home of its own, with a fake opencode that
|
||||||
|
logs every call."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
ws.mkdir(parents=True)
|
||||||
|
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))
|
||||||
|
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
|
||||||
|
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
||||||
|
db.parent.mkdir(parents=True)
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
for stmt in V2_DDL:
|
||||||
|
conn.execute(stmt)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def calls():
|
||||||
|
return log.read_text().splitlines() if log.exists() else []
|
||||||
|
|
||||||
|
return adapter, ctx, db, ws, calls
|
||||||
|
|
||||||
|
|
||||||
|
def _add(db, sid, ws, ms, parent=None):
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("INSERT INTO session_v2 VALUES (?, ?, ?, ?)", (sid, parent, str(ws), ms))
|
||||||
|
conn.execute("INSERT INTO session_message VALUES (?, ?)", (f"msg-{sid}", sid))
|
||||||
|
conn.execute("INSERT INTO event_sequence VALUES (?, 1)", (sid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(db, sid):
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
try:
|
||||||
|
return (conn.execute("SELECT count(*) FROM session_v2 WHERE id=?", (sid,)).fetchone()[0]
|
||||||
|
+ conn.execute("SELECT count(*) FROM session_message WHERE session_id=?", (sid,)).fetchone()[0]
|
||||||
|
+ conn.execute("SELECT count(*) FROM event_sequence WHERE aggregate_id=?", (sid,)).fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_db_comes_from_debug_paths(oc):
|
||||||
|
adapter, ctx, db, ws, calls = oc
|
||||||
|
assert adapter._resolve_db(ctx) == str(db)
|
||||||
|
assert calls() == ["debug paths"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_and_verify_read_session_v2_and_skip_child_sessions(oc):
|
||||||
|
adapter, ctx, db, ws, calls = oc
|
||||||
|
_add(db, "ses_main", ws, 2_000_000)
|
||||||
|
_add(db, "ses_child", ws, 3_000_000, parent="ses_main") # a subagent's session: newer, same dir
|
||||||
|
_add(db, "ses_old", ws, 500_000)
|
||||||
|
ctx.epoch = 1000
|
||||||
|
assert adapter.discover(ctx) == ["ses_main"]
|
||||||
|
assert adapter.verify_artifact("ses_main", ctx) is True
|
||||||
|
assert adapter.verify_artifact("ses_old", ctx) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_2x_database_has_no_session_table_and_still_works(oc):
|
||||||
|
adapter, ctx, db, ws, calls = oc
|
||||||
|
_add(db, "ses_main", ws, 2_000_000)
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
assert conn.execute("SELECT count(*) FROM sqlite_master WHERE name='session'").fetchone()[0] == 0
|
||||||
|
conn.close()
|
||||||
|
assert adapter.discover(ctx) == ["ses_main"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_asks_opencode_to_delete_the_session_and_its_children(oc):
|
||||||
|
adapter, ctx, db, ws, calls = oc
|
||||||
|
_add(db, "ses_main", ws, 2_000_000)
|
||||||
|
_add(db, "ses_child", ws, 3_000_000, parent="ses_main")
|
||||||
|
_add(db, "ses_other", ws, 2_500_000)
|
||||||
|
purged = adapter.purge_artifacts("ses_main", ctx)
|
||||||
|
assert purged == ["opencode session (with its child sessions): ses_main"] # no 1.x tables: nothing else
|
||||||
|
assert "session delete --standalone -- ses_main" in calls()
|
||||||
|
assert _rows(db, "ses_main") == 0 and _rows(db, "ses_child") == 0
|
||||||
|
assert _rows(db, "ses_other") == 3
|
||||||
|
assert adapter.residual_artifacts("ses_main", ctx) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_does_not_run_opencode_for_a_session_it_does_not_hold(oc):
|
||||||
|
adapter, ctx, db, ws, calls = oc
|
||||||
|
assert adapter.purge_artifacts("ses_missing", ctx) == []
|
||||||
|
assert not any(c.startswith("session delete") for c in calls())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("broken", ["exit", "missing"])
|
||||||
|
def test_a_failed_delete_is_warned_and_reported(oc, monkeypatch, capsys, broken):
|
||||||
|
adapter, ctx, db, ws, calls = oc
|
||||||
|
_add(db, "ses_main", ws, 2_000_000)
|
||||||
|
if broken == "missing":
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
||||||
|
want = "no opencode binary"
|
||||||
|
else:
|
||||||
|
failing = Path(ctx.home_dir).parent / "fakebin" / "failing"
|
||||||
|
failing.write_text('#!/bin/sh\n[ "$1" = debug ] && exec "$(dirname "$0")/opencode-real" "$@"\necho boom >&2\nexit 3\n')
|
||||||
|
failing.chmod(0o755)
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing))
|
||||||
|
want = "exited 3: boom"
|
||||||
|
assert adapter.purge_artifacts("ses_main", ctx) == []
|
||||||
|
assert want in capsys.readouterr().err
|
||||||
|
assert f"{adapter._resolve_db(ctx)} (session_v2.id: 1 rows)" in adapter.residual_artifacts("ses_main", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_and_resume_never_pass_agent_and_run_standalone():
|
||||||
|
"""The shell copies of the opencode command line match the adapter."""
|
||||||
|
adapter = get_adapter("opencode")
|
||||||
|
assert adapter.spawn_spec("opencode") == "opencode --auto --standalone"
|
||||||
|
assert adapter.resume_spec("opencode", "ses_x", materialized=True) == "opencode --session ses_x --auto --standalone"
|
||||||
|
for rel in ("multi-agent-mux-create/scripts/create_session.sh",
|
||||||
|
"multi-agent-mux-resume/scripts/resume_session.sh",
|
||||||
|
"multi-agent-mux-resume/scripts/update_yaml_resumed.sh"):
|
||||||
|
text = (SKILLS / rel).read_text()
|
||||||
|
lines = [l for l in text.splitlines() if "opencode" in l and "--auto" in l]
|
||||||
|
assert lines, rel
|
||||||
|
for l in lines:
|
||||||
|
assert "--agent" not in l and "--standalone" in l, (rel, l)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_and_reconcile_read_session_v2():
|
||||||
|
for rel in ("multi-agent-mux-status/scripts/status.sh", "multi-agent-mux-monitor/scripts/reconcile.sh"):
|
||||||
|
text = (SKILLS / rel).read_text()
|
||||||
|
assert not re.search(r"FROM session\b(?!_)|table_info\(session\)", text), rel
|
||||||
|
assert "session_v2" in text, rel
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# a real opencode 2.x, in a home of its own
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _real_opencode():
|
||||||
|
path = shutil.which("opencode") or os.path.expanduser("~/.opencode/bin/opencode")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20).stdout
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
m = re.search(r"(\d+)\.\d+\.\d+", out)
|
||||||
|
return path if m and int(m.group(1)) >= 2 else None
|
||||||
|
|
||||||
|
|
||||||
|
REAL = _real_opencode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(REAL is None, reason="no opencode 2.x installed")
|
||||||
|
def test_real_opencode_2x_contract(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
ws = tmp_path / "ws"
|
||||||
|
for d in (home, ws, tmp_path / "tmp"):
|
||||||
|
d.mkdir()
|
||||||
|
ws_real = os.path.realpath(str(ws))
|
||||||
|
monkeypatch.setenv("HOME", str(home))
|
||||||
|
monkeypatch.setenv("TMPDIR", str(tmp_path / "tmp"))
|
||||||
|
for k in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "HOME_DIR"):
|
||||||
|
monkeypatch.delenv(k, raising=False)
|
||||||
|
env = {**os.environ, "TERM": "dumb"}
|
||||||
|
|
||||||
|
def cli(*args, check=True):
|
||||||
|
res = subprocess.run([REAL, *args], capture_output=True, text=True, timeout=120, cwd=ws_real, env=env)
|
||||||
|
if check:
|
||||||
|
assert res.returncode == 0, (args, res.stdout, res.stderr)
|
||||||
|
return res
|
||||||
|
|
||||||
|
help_text = cli("--help").stdout
|
||||||
|
assert "--standalone" in help_text and "--session" in help_text
|
||||||
|
assert not re.search(r"^\s*--agent\b", help_text, re.M), "the TUI takes --agent again: revisit spawn_spec"
|
||||||
|
|
||||||
|
import json
|
||||||
|
parent = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d", '{"title":"mam test"}').stdout)["data"]["id"]
|
||||||
|
child = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d",
|
||||||
|
json.dumps({"title": "mam child", "parentID": parent})).stdout)["data"]["id"]
|
||||||
|
|
||||||
|
adapter = get_adapter("opencode")
|
||||||
|
ctx = DiscoveryContext(workspace=ws_real, agent_name="opencode", home_dir=str(home))
|
||||||
|
db = adapter._resolve_db(ctx)
|
||||||
|
assert db == str(home / ".local" / "share" / "opencode" / "opencode.db")
|
||||||
|
assert adapter.discover(ctx) == [parent]
|
||||||
|
assert adapter.verify_artifact(child, ctx) is True
|
||||||
|
|
||||||
|
purged = adapter.purge_artifacts(parent, ctx)
|
||||||
|
assert purged == [f"opencode session (with its child sessions): {parent}"]
|
||||||
|
assert adapter.residual_artifacts(parent, ctx) == []
|
||||||
|
assert adapter.residual_artifacts(child, ctx) == []
|
||||||
|
gone = cli("session", "delete", "--standalone", "--", parent, check=False)
|
||||||
|
assert gone.returncode == 1 and "Session not found" in gone.stdout + gone.stderr
|
||||||
Reference in new issue
Block a user