Compare commits

..
3 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 6b4b96fa18 docs(opencode): 2.x only; the TUI takes no -m/--agent; MAM spawns --standalone (Issue #60)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 14:58:29 +09:00
GodopuandClaude Opus 5.5 f0d1c000f9 test(opencode): the 2.x schema and CLI, a fake opencode, and an opt-in real-binary contract (Issue #60)
tests/_fake_opencode.py implements the two commands MAM calls (debug paths,
session delete) as measured on 2.0.32; unit tests never run a real opencode
(it would migrate the test database). test_real_opencode_2x_contract runs a
real 2.x opencode, when installed, in a home and tmp dir of its own with
--standalone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 14:58:29 +09:00
GodopuandClaude Opus 5.5 a311c23303 fix(opencode): support opencode 2.x only - session_v2, debug paths, session delete, --standalone (Issue #60)
Measured on opencode 2.0.32 (macOS):
- the TUI rejects --agent ("Unrecognized flag"), so create and resume could
  not start opencode at all; the default agent is build. MAM now spawns
  `opencode --auto --standalone` (resume adds --session <id>): the server
  runs as the TUI's child and ends with it, and the per-user background
  service is neither used nor started;
- sessions live 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). discover,
  verify_artifact, reconcile drift C and status read session_v2, and
  discover / drift C skip child (subagent) sessions;
- `db path` is gone (it prints the help and exits 0): the db comes from
  the `db` line of `opencode debug paths`, else the default path;
- `opencode session delete` removes a session with its child sessions from
  the 2.x tables (and leaves the 1.x ones): purge runs it with --standalone
  for a session in session_v2, still deletes the 1.x message/part/session
  rows, and residual also reports a session_v2 row left by a failed delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 14:58:29 +09:00
13 changed files with 460 additions and 275 deletions

No files matched your search

+90 -114
View File
@@ -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 _bin(self, ctx: DiscoveryContext) -> Optional[str]:
bin_path = shutil.which("opencode")
if bin_path:
return bin_path
for candidate_bin in [
f"{ctx.home_dir}/.opencode/bin/opencode",
os.path.expanduser("~/.opencode/bin/opencode"),
]:
if os.path.exists(candidate_bin):
return candidate_bin
return None
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)
if ctx.home_dir:
env["HOME"] = ctx.home_dir
env["HOME_DIR"] = ctx.home_dir
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
cwd = ctx.workspace or ctx.cwd or os.getcwd()
return subprocess.run([bin_path] + args, capture_output=True, text=True, timeout=timeout,
env=env, cwd=cwd if os.path.exists(cwd) else None)
def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]: def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]:
# 1. Try opencode CLI "opencode 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.
try: try:
import subprocess res = self._run(ctx, ["debug", "paths"], timeout=5)
bin_path = shutil.which("opencode") if res is not None and res.returncode == 0:
if not bin_path: for line in res.stdout.splitlines():
for candidate_bin in [ parts = line.split(None, 1)
f"{ctx.home_dir}/.opencode/bin/opencode", if len(parts) == 2 and parts[0] == "db":
os.path.expanduser("~/.opencode/bin/opencode"), return parts[1].strip()
]:
if os.path.exists(candidate_bin):
bin_path = candidate_bin
break
if bin_path:
env = dict(os.environ)
if ctx.home_dir:
env["HOME"] = ctx.home_dir
env["HOME_DIR"] = ctx.home_dir
env["XDG_DATA_HOME"] = os.path.join(ctx.home_dir, ".local/share")
cwd = ctx.workspace or ctx.cwd or os.getcwd()
res = subprocess.run(
[bin_path, "db", "path"],
capture_output=True,
text=True,
timeout=2,
env=env,
cwd=cwd if os.path.exists(cwd) else None
)
if res.returncode == 0:
out = res.stdout.strip()
if out and os.path.exists(out):
return out
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()
purged.append(f"sqlite rows for session: {uuid}") if tables & {"message", "messages", "part", "parts", "session", "sessions"}:
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)) query += " ORDER BY time_created DESC LIMIT 20"
else:
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:
+1 -1
View File
@@ -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일** |
+2 -4
View File
@@ -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. 세션 생성 및 온보딩
+64
View File
@@ -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
View File
@@ -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"
+41 -121
View File
@@ -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") conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)")
tables = {row[0] for row in cursor.fetchall()} conn.execute("INSERT INTO session_message VALUES ('sm1', 'uuid-o')")
if "message" not in tables: conn.execute("INSERT INTO message VALUES ('m1', 'uuid-o')")
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)") conn.execute("INSERT INTO part VALUES ('p1', 'uuid-o')")
if "part" not in tables:
conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)")
cursor.execute("PRAGMA table_info(message)")
m_cols = [c[1] for c in cursor.fetchall()]
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
+15 -5
View File
@@ -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):
+228
View File
@@ -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