fix(sessions): recover home-scoped list after fresh connect

* fix(sessions): use active connection client directly, remove roots filter

Root cause A: loadSessions was calling clientForDirectory(serverHome) which
scoped the session list to /home/azureuser — a different project than the
server's active CWD. Sessions in the current project (e.g. opencode-mobile)
were never returned.

Root cause B: roots:true filtered out sessions that have a parentID (sub-task /
AUTO-REVIEW sessions), hiding valid sessions from the list.

Fix: use connState.client directly (the connection's active directory) and drop
the roots filter so all sessions for that project are visible.

Also adds a verify_session_list CUA smoke scenario that navigates back to the
sessions tab after creating a session and asserts the list is non-empty —
covering the regression path that was previously untested.

* fix(sessions): fetch serverHome in addConnection so loadSessions shows correct sessions

Root cause: addConnection() built the HTTP client but never fetched serverHome
(only loadConnections and setActiveConnection did). When the user adds a new
connection (fresh install / first sign-in), serverHome = null, so loadSessions
fell through to connState.client (the server's CWD). On this dev server the CWD
is the deploy directory — 11 old May-19 sessions that are not the user's recent
work sessions.

Fix: addConnection now fetches currentProject + serverHome via the same
Promise.all as setActiveConnection, before calling set(). This ensures
loadSessions immediately uses clientForDirectory(serverHome) → the global
project → the user's actual recent parent sessions.

Also adds --opencode-url flag to the CUA smoke script, which appends a
connect_and_verify_sessions scenario that reproduces the regression:
  python scripts/android-cua-smoke.py --opencode-url http://100.108.64.76:4096

* fix(sessions): recover home scope after fresh connect

Resolve stale deploy-only session list by recovering server home during first load and keeping regression coverage in default Android CUA smoke and CI.

* chore(release): bump version to 0.4.0
This commit is contained in:
Den
2026-05-26 19:50:17 -07:00
committed by GitHub
parent 64aa4a3bce
commit 32f7af4e11
14 changed files with 431 additions and 15 deletions

View File

@@ -50,6 +50,8 @@ import sys
import tempfile
import time
import threading
import re
import xml.etree.ElementTree as ET
from pathlib import Path
try:
@@ -76,6 +78,107 @@ def adb(*args: str) -> str:
_step_counter = 0
APP_PACKAGE = "ai.opencode.mobile"
def _bounds_center(bounds: str) -> tuple[int, int] | None:
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds or "")
if not match:
return None
x1, y1, x2, y2 = map(int, match.groups())
return ((x1 + x2) // 2, (y1 + y2) // 2)
def current_foreground_package() -> str:
"""Return resumed foreground package name when available."""
out = adb("shell", "dumpsys", "activity", "activities")
for line in out.splitlines():
if "mResumedActivity" not in line:
continue
match = re.search(r"\s([a-zA-Z0-9_\.]+)/", line)
if match:
return match.group(1)
return ""
def ensure_app_foreground(package: str = APP_PACKAGE, retries: int = 3,
verbose: bool = True) -> bool:
"""Bring app to foreground before scenario start."""
for attempt in range(retries):
current = current_foreground_package()
if current == package:
return True
adb("shell", "monkey", "-p", package, "-c", "android.intent.category.LAUNCHER", "1")
time.sleep(2.0)
if verbose:
seen = current or "unknown"
print(f" [prep] foreground package was '{seen}', launched '{package}' (attempt {attempt + 1}/{retries})")
return current_foreground_package() == package
def maybe_dismiss_telemetry_consent(package: str = APP_PACKAGE,
verbose: bool = True) -> bool:
"""Dismiss first-launch telemetry consent modal when present."""
xml = ui_dump()
if not xml:
return False
try:
root = ET.fromstring(xml)
except ET.ParseError:
return False
consent_markers = (
"help improve opencode",
"share anonymous crash reports",
)
dismiss_markers = (
"not now", "no thanks", "decline", "skip", "later",
"don't allow", "dont allow", "deny", "continue without",
"opt out", "cancel",
)
page_text = " ".join(
" ".join(filter(None, [
node.attrib.get("text", ""),
node.attrib.get("content-desc", ""),
])).lower()
for node in root.iter()
)
if not any(marker in page_text for marker in consent_markers):
return False
candidates = []
for node in root.iter():
clickable = node.attrib.get("clickable") == "true"
if not clickable:
continue
label = " ".join(filter(None, [
node.attrib.get("text", ""),
node.attrib.get("content-desc", ""),
node.attrib.get("resource-id", ""),
])).strip().lower()
center = _bounds_center(node.attrib.get("bounds", ""))
if not center:
continue
candidates.append((label, center))
for label, (x, y) in candidates:
if any(marker in label for marker in dismiss_markers):
adb("shell", "input", "tap", str(x), str(y))
time.sleep(1.0)
if verbose:
print(f" [prep] dismissed telemetry consent via '{label or 'button'}' at ({x}, {y})")
return True
if verbose:
print(" [prep] telemetry consent detected but dismiss button was not found")
return False
def screenshot_b64() -> str:
@@ -255,7 +358,6 @@ def execute_action(action: dict) -> str:
elif act == "send":
# Auto-locate send button: rightmost clickable ViewGroup in the bottom input bar
import re
xml = ui_dump()
# Find the EditText (message input) and the clickable element immediately after it
# The send button is the last clickable ViewGroup in the input row
@@ -476,8 +578,35 @@ SMOKE_SCENARIOS = [
"Report success if you see two assistant reply bubbles."
),
},
{
"name": "verify_session_list",
"goal": (
"You see the OpenCode mobile app. Tap the '+' button (top-right) to create a new session. "
"Wait 2 seconds for the session to be created. "
"Navigate back to the sessions list by tapping the bottom-left 'Sessions' tab or pressing the back button. "
"Wait 3 seconds for the session list to load. "
"Report success if you can see at least one session entry in the list. "
"Report failure if the sessions list appears empty or shows an error message."
),
},
]
# Extended scenarios requiring an external OpenCode server.
# Run with: python scripts/android-cua-smoke.py --opencode-url http://<host>:<port>
def _connect_and_verify_sessions_goal(url: str) -> str:
return (
f"You see the OpenCode mobile app. "
"Go to the Connections tab (bottom navigation bar). "
"If a connection to the server already exists, tap it to make it active and skip to the next step. "
"Otherwise tap '+' or 'Add Connection', "
f"enter the URL '{url}', leave username/password blank, tap Save or Connect. "
"Wait 3 seconds. "
"Now navigate to the Sessions tab (bottom navigation bar). "
"Wait 5 seconds for sessions to load. "
"Report SUCCESS if you see at least one session listed (a session title is visible). "
"Report FAILURE if the sessions list is empty, shows 'No sessions yet', or shows an error."
)
def main():
parser = argparse.ArgumentParser(description="Android CUA smoke test")
@@ -486,6 +615,16 @@ def main():
parser.add_argument("--max-steps", type=int, default=30)
parser.add_argument("--include-xml", action="store_true", help="Include UI XML in context")
parser.add_argument("--quiet", action="store_true")
parser.add_argument(
"--opencode-url",
help="OpenCode server URL (e.g. http://100.108.64.76:4096). "
"Used by the default connect-and-verify regression scenario.",
)
parser.add_argument(
"--skip-connect-scenario",
action="store_true",
help="Skip the default connect-and-verify regression scenario.",
)
args = parser.parse_args()
# Verify ADB
@@ -496,7 +635,15 @@ def main():
except FileNotFoundError:
sys.exit("adb not found in PATH")
scenarios = [{"name": "custom", "goal": args.goal}] if args.goal else SMOKE_SCENARIOS
scenarios = [{"name": "custom", "goal": args.goal}] if args.goal else list(SMOKE_SCENARIOS)
# Keep connect-and-verify in the default smoke path so regressions are exercised.
if not args.goal and not args.skip_connect_scenario:
connect_url = args.opencode_url or os.environ.get("OPENCODE_URL") or "http://100.108.64.76:4096"
scenarios.append({
"name": "connect_and_verify_sessions",
"goal": _connect_and_verify_sessions_goal(connect_url),
})
results = []
for scenario in scenarios:
@@ -511,6 +658,11 @@ def main():
local_video = f"/tmp/cua_{scenario['name']}.mp4"
try:
if not ensure_app_foreground(verbose=not args.quiet):
print(f" [prep] warning: could not confirm {APP_PACKAGE} in foreground")
maybe_dismiss_telemetry_consent(verbose=not args.quiet)
ensure_app_foreground(verbose=not args.quiet)
result = run_cua(
goal=scenario["goal"],
max_steps=args.max_steps,