diff --git a/android-studio-plugin/.gitignore b/android-studio-plugin/.gitignore new file mode 100644 index 00000000..20d4f83f --- /dev/null +++ b/android-studio-plugin/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +.intellijPlatform/ +local.properties +.idea/ +*.iml diff --git a/android-studio-plugin/README.md b/android-studio-plugin/README.md new file mode 100644 index 00000000..95fe6f4a --- /dev/null +++ b/android-studio-plugin/README.md @@ -0,0 +1,151 @@ + + +# ARTEMIS Android Studio Plugin (Phase 1 MVP) + +A thin-client Android Studio / IntelliJ plugin for +[ARTEMIS](../README.md), a natural-language Android on-device automation +framework. The plugin contains **no agent logic**: it talks to a running +ARTEMIS server over its HTTP API and nothing else. + +## What it does + +- **Settings** page (Settings → Tools → ARTEMIS): server base URL + (default `http://localhost:8000`), default profile (`flash` / `pro`), and an + optional default device serial. +- **ARTEMIS tool window** (right-hand side): + - Server status indicator (`GET /api/status`). + - Device dropdown populated from `GET /api/devices`, with a refresh button. + - Prompt text area + **Run Task** button → `POST /api/run` (disabled while + the server is unreachable). + - Session task list with live status polling (`GET /api/sessions/{id}`, every + ~2 s while a task is active, on background coroutines; the Swing UI is only + ever updated on the EDT). + - Detail pane for the selected task (status, turn counts, result summary, + error text) and a **Stop** button → `POST /api/stop`. +- **Notifications** for task completion / failure (balloon group + `ARTEMIS_TASKS`). + +The Kotlin model classes mirror the Python client models in +[`packages/artemis-client`](../packages/artemis-client/src/artemis_client/models.py) +field-for-field, including the legacy payload fallbacks +(`session_id`/`task_id`/`trace_id`, `current_turn`/`turns`, +`output`/`result`/`summary`, wrapped or bare `/api/devices` lists, scheduler +queue fallback for `GET /api/sessions/{id}` 404s, and the legacy capabilities +baseline). + +## Compatibility + +| IDE | Version | Build range | +|---|---|---| +| Android Studio | Hedgehog 2023.1.1+ (Hedgehog → Narwhal and newer) | 231+ | +| IntelliJ IDEA (Community/Ultimate) | 2023.1+ | 231+ | + +The plugin is declared with `since-build="231"` and **no** `until-build` cap. +It is compiled against IntelliJ IDEA Community 2024.3 (see +`gradle.properties`). + +## Prerequisites + +1. An ARTEMIS server running and reachable, e.g. the default + `http://localhost:8000` (see the repo root README for how to start it). +2. At least one Android device attached/visible to that server + (`GET /api/devices` non-empty), unless you rely on the server-side default. +3. JDK/JBR 17 to build (IntelliJ plugin development requires JVM 17). The + checked-in `gradle.properties` points `org.gradle.java.home` at a JBR 17 + path; adjust it for your machine if needed. + +## Build + +```bash +cd android-studio-plugin +./gradlew buildPlugin +``` + +The first build downloads the IntelliJ Platform distribution (~1 GB+), so it +can take a while. The plugin ZIP is produced at: + +``` +build/distributions/artemis-android-studio-plugin-0.1.0.zip +``` + +Other useful tasks: + +```bash +./gradlew test # unit + HTTP-integration tests (no IDE needed at runtime) +./gradlew verifyPluginStructure # sanity-check the assembled plugin layout +./gradlew runIde # launch a sandbox IDE with the plugin installed +``` + +To build against a locally installed IDE instead of downloading the platform +(for example Android Studio itself): + +```bash +./gradlew buildPlugin -PplatformLocalPath="/Applications/Android Studio.app/Contents" +``` + +## Install from disk + +1. Build the ZIP (above). +2. In Android Studio: **Settings → Plugins → ⚙ → Install Plugin from Disk…** +3. Select `build/distributions/artemis-android-studio-plugin-0.1.0.zip` and restart the IDE. +4. Open the **ARTEMIS** tool window on the right, and (optionally) adjust the + server URL under **Settings → Tools → ARTEMIS**. + +## Architecture + +``` ++------------------+ HTTP (JSON) +------------------+ adb +----------+ +| Android Studio | GET /api/status | ARTEMIS server | ----------> | Android | +| ARTEMIS plugin | GET /api/devices | (scheduler + | | device | +| (thin client, | POST /api/run | agent runtime, | | | +| no agent code) | GET /api/sessions/{id} | LLM providers) | | | +| | POST /api/stop | | | | ++------------------+ +------------------+ +----------+ +``` + +Package layout (`src/main/kotlin/com/google/artemis/studio/`): + +| Package | Contents | +|---|---| +| `model` | Data classes mirroring the Python client models + `RunRequest` payload builder | +| `api` | `ArtemisApiClient` (JDK `java.net.http.HttpClient` + Gson) and typed exceptions | +| `settings` | `PersistentStateComponent` + `Configurable` (Settings → Tools → ARTEMIS) | +| `services` | `TaskPollingService` (2 s status polling, coroutine-based) + notifications | +| `toolwindow` | `ArtemisToolWindowFactory` + the tool window panel | + +JSON uses Gson, which is bundled with the IntelliJ Platform — the plugin has +no external runtime dependencies. + +## Testing + +```bash +./gradlew test +``` + +The suite runs entirely outside the IDE: + +- **Model parsing** (`ModelParsingTest`): canned payloads mirrored from + `packages/artemis-client/tests/test_client.py` (legacy device shape, + terminal session payload, capability list/map forms, field-name fallbacks). +- **Request building** (`RunRequestTest`): `POST /api/run` payload shape + (`goal` / `profile` / `session_id` / `ingress`, optional-field omission, + Pro tuning-knob normalization, early rejection of invalid values). +- **HTTP integration** (`ArtemisApiClientIntegrationTest`): the client against + the JDK's built-in `com.sun.net.httpserver.HttpServer` — full + submit → poll → stop flow with wire-payload assertions, task rejection, + scheduler-queue fallback for session 404s, legacy capabilities fallback, + HTTP 500, unreachable server, and malformed `/api/devices` payloads. diff --git a/android-studio-plugin/build.gradle.kts b/android-studio-plugin/build.gradle.kts new file mode 100644 index 00000000..518d819b --- /dev/null +++ b/android-studio-plugin/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + kotlin("jvm") version "2.0.21" + id("org.jetbrains.intellij.platform") version "2.2.1" +} + +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + val localPath = providers.gradleProperty("platformLocalPath").orNull + if (!localPath.isNullOrBlank()) { + local(localPath) + } else { + create( + providers.gradleProperty("platformType").get(), + providers.gradleProperty("platformVersion").get(), + ) + } + } + + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +intellijPlatform { + pluginConfiguration { + id = providers.gradleProperty("pluginGroup") + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + description = providers.provider { + "Thin-client Android Studio integration for the ARTEMIS on-device " + + "automation framework. Submits natural-language tasks to a running " + + "ARTEMIS server over HTTP and tracks their progress." + } + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + // No untilBuild cap: keep the plugin loadable on future IDEs. + @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") + untilBuild = providers.provider { null } + } + } +} + +tasks { + withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + withType().configureEach { + sourceCompatibility = "17" + targetCompatibility = "17" + } + test { + useJUnitPlatform() + } +} diff --git a/android-studio-plugin/gradle.properties b/android-studio-plugin/gradle.properties new file mode 100644 index 00000000..40e8bb9e --- /dev/null +++ b/android-studio-plugin/gradle.properties @@ -0,0 +1,19 @@ +# IntelliJ Platform Gradle Plugin configuration +pluginGroup=com.google.artemis.studio +pluginName=ARTEMIS +pluginVersion=0.1.0 + +# Target platform: IntelliJ IDEA Community 2024.3. +# sinceBuild 231 covers Android Studio Hedgehog (2023.1.1, 231) through current. +platformType=IC +platformVersion=2024.3 +pluginSinceBuild=231 + +# Optional: absolute path to a local IDE installation (Android Studio or IntelliJ) +# to build against instead of downloading the platform distribution, e.g. +# ./gradlew buildPlugin -PplatformLocalPath="/Applications/Android Studio.app/Contents" + +# Gradle / JVM +org.gradle.java.home=/Users/ranjith/Library/Java/JavaVirtualMachines/jbr-17.0.12/Contents/Home +org.gradle.caching=true +org.gradle.parallel=true diff --git a/android-studio-plugin/gradle/wrapper/gradle-wrapper.jar b/android-studio-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/android-studio-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-studio-plugin/gradle/wrapper/gradle-wrapper.properties b/android-studio-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/android-studio-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-studio-plugin/gradlew b/android-studio-plugin/gradlew new file mode 100755 index 00000000..d95bf613 --- /dev/null +++ b/android-studio-plugin/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android-studio-plugin/gradlew.bat b/android-studio-plugin/gradlew.bat new file mode 100644 index 00000000..640d6868 --- /dev/null +++ b/android-studio-plugin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-studio-plugin/settings.gradle.kts b/android-studio-plugin/settings.gradle.kts new file mode 100644 index 00000000..8f0a3adc --- /dev/null +++ b/android-studio-plugin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "artemis-android-studio-plugin" diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/api/ArtemisApiClient.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/api/ArtemisApiClient.kt new file mode 100644 index 00000000..eb233d18 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/api/ArtemisApiClient.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.api + +import com.google.artemis.studio.model.Capabilities +import com.google.artemis.studio.model.Device +import com.google.artemis.studio.model.RunRequest +import com.google.artemis.studio.model.TaskHandle +import com.google.artemis.studio.model.TaskResult +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.IOException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** + * Thin HTTP client for a running ARTEMIS server. + * + * Mirrors ArtemisClient in packages/artemis-client (client.py). All methods are + * blocking and must be called off the Swing EDT (the plugin invokes them from + * background coroutines). Every failure surfaces as an [ArtemisApiException] + * subclass with a human-readable message; nothing is thrown raw onto the UI. + */ +class ArtemisApiClient( + baseUrl: String, + private val requestTimeout: Duration = Duration.ofSeconds(30), + connectTimeout: Duration = Duration.ofSeconds(10), +) { + private val baseUrl: String = baseUrl.trim().trimEnd('/') + + // Force HTTP/1.1: java.net.http defaults to HTTP/2 and attempts an h2c + // upgrade on the first request. Uvicorn (the ARTEMIS server) does not + // support cleartext upgrade and silently drops the POST body while + // processing it, so FastAPI then fails validation with 422 "body: Field + // required". Verified against a live server (2026-09-30). + private val http: HttpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(connectTimeout) + .build() + private val gson = Gson() + + init { + require(this.baseUrl.isNotEmpty()) { "ARTEMIS server URL must not be empty" } + } + + /** Fast liveness check against the scheduler status API. */ + fun health(): JsonObject = requestObject("GET", "/api/status") + + /** Full device/toolchain readiness diagnostics (can be slow). */ + fun readiness(): JsonObject = requestObject("GET", "/api/system/readiness") + + /** Discover server features, falling back to the legacy baseline on HTTP 404. */ + fun capabilities(): Capabilities = try { + Capabilities.fromPayload(requestObject("GET", "/api/v1/capabilities")) + } catch (e: ArtemisNotFoundException) { + Capabilities(apiVersion = "legacy", features = Capabilities.LEGACY_FEATURES) + } + + /** List Android devices visible to the remote ARTEMIS host. */ + fun listDevices(): List { + val payload = request("GET", "/api/devices") + // Matches client.py: a bare array is accepted; for a wrapped payload a + // missing "devices" key yields an empty list, while an explicit null or + // a non-list value is a protocol error. + val rawDevices: JsonElement? = when { + payload == null || payload.isJsonNull -> null + payload.isJsonObject -> { + val obj = payload.asJsonObject + if (!obj.has("devices")) return emptyList() + obj.get("devices") + } + payload.isJsonArray -> payload + else -> null + } + if (rawDevices == null || !rawDevices.isJsonArray) { + throw ArtemisApiException("/api/devices response must contain a device list") + } + return rawDevices.asJsonArray.map { entry -> + if (!entry.isJsonObject) { + throw ArtemisApiException("/api/devices contained a non-object device entry") + } + Device.fromPayload(entry.asJsonObject) + } + } + + /** + * Submit one task and return immediately after scheduler admission. + * Throws [ArtemisTaskRejectedException] when the scheduler refuses the task. + */ + fun submit(request: RunRequest): TaskHandle { + val response = requestObject("POST", "/api/run", request.toJsonObject()) + val status = response.get("status") + ?.takeUnless { it.isJsonNull } + ?.let { runCatching { it.asString }.getOrNull() } + ?.lowercase() + ?: "unknown" + val tasks = response.get("tasks") + if (status == "rejected" || (tasks != null && tasks.isJsonArray && tasks.asJsonArray.size() == 0)) { + val detail = response.get("error") + ?.takeUnless { it.isJsonNull } + ?.let { runCatching { it.asString }.getOrNull() } + ?: "no reason given" + throw ArtemisTaskRejectedException(detail) + } + if (tasks == null || !tasks.isJsonArray || tasks.asJsonArray.size() == 0 || + !tasks.asJsonArray[0].isJsonObject + ) { + throw ArtemisApiException("/api/run response did not contain an admitted task") + } + val taskPayload = tasks.asJsonArray[0].asJsonObject.deepCopy() + // Match client.py's setdefault semantics exactly: the client-generated + // idempotency ID is injected whenever the server omitted "session_id", + // so it wins over a bare server-side "id" during TaskHandle parsing. + if (!taskPayload.has("session_id")) { + taskPayload.addProperty("session_id", request.resolvedTaskId) + } + if (!taskPayload.has("status")) { + taskPayload.addProperty("status", status) + } + return TaskHandle.fromPayload(taskPayload) + } + + /** Get a task from session storage, falling back to the live scheduler queue. */ + fun getTask(taskId: String): TaskResult { + return try { + TaskResult.fromPayload(requestObject("GET", "/api/sessions/$taskId"), taskId) + } catch (e: ArtemisNotFoundException) { + val scheduler = requestObject("GET", "/api/status") + val liveTask = findLiveTask(scheduler, taskId) + if (liveTask == null) { + TaskResult(taskId = taskId, status = "launching") + } else { + TaskResult.fromPayload(liveTask, taskId) + } + } + } + + /** Request cancellation of one remote task. Returns true when the server confirms. */ + fun stop(taskId: String): Boolean { + val body = JsonObject().apply { addProperty("session_id", taskId) } + val payload = requestObject("POST", "/api/stop", body) + val status = payload.get("status") + ?.takeUnless { it.isJsonNull } + ?.let { runCatching { it.asString }.getOrNull() } + return status?.lowercase() == "stopped" + } + + private fun requestObject(method: String, path: String, body: JsonObject? = null): JsonObject { + val payload = request(method, path, body) + if (payload == null || !payload.isJsonObject) { + throw ArtemisApiException("$path response must be a JSON object") + } + return payload.asJsonObject + } + + private fun request(method: String, path: String, body: JsonObject? = null): JsonElement? { + val builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .timeout(requestTimeout) + if (body == null) { + builder.method(method, HttpRequest.BodyPublishers.noBody()) + } else { + builder + .header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(gson.toJson(body))) + } + val response: HttpResponse = try { + http.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + } catch (e: IOException) { + throw ArtemisServerUnreachableException( + "Cannot reach ARTEMIS server at $baseUrl (${e.message ?: "connection failed"})", e + ) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw ArtemisServerUnreachableException("Request to $baseUrl$path was interrupted", e) + } catch (e: IllegalArgumentException) { + throw ArtemisApiException("Invalid ARTEMIS server URL: $baseUrl", e) + } + + val responseBody = response.body() + when { + response.statusCode() == 404 -> throw ArtemisNotFoundException(path, responseBody) + response.statusCode() !in 200..299 -> + throw ArtemisHttpException(response.statusCode(), path, responseBody) + } + if (responseBody.isNullOrBlank()) return null + return try { + JsonParser.parseString(responseBody) + } catch (e: Exception) { + throw ArtemisApiException("$path returned invalid JSON: ${responseBody.take(200)}", e) + } + } + + companion object { + /** Locate a task in the scheduler status payload (queue, active tasks, or top level). */ + internal fun findLiveTask(scheduler: JsonObject, taskId: String): JsonObject? { + for (collectionName in listOf("queue", "active_tasks")) { + val collection = scheduler.get(collectionName) ?: continue + if (!collection.isJsonArray) continue + for (item in collection.asJsonArray) { + if (!item.isJsonObject) continue + val itemId = item.asJsonObject.get("task_id") ?: item.asJsonObject.get("session_id") + if (itemId != null && !itemId.isJsonNull && + runCatching { itemId.asString }.getOrNull() == taskId + ) { + return item.asJsonObject + } + } + } + val activeId = scheduler.get("task_id") ?: scheduler.get("session_id") + if (activeId != null && !activeId.isJsonNull && + runCatching { activeId.asString }.getOrNull() == taskId + ) { + val synthesized = scheduler.deepCopy() + if (!synthesized.has("session_id")) synthesized.addProperty("session_id", taskId) + return synthesized + } + return null + } + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/api/ArtemisApiException.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/api/ArtemisApiException.kt new file mode 100644 index 00000000..fa69f535 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/api/ArtemisApiException.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.api + +/** Base class for all ARTEMIS server communication failures. */ +open class ArtemisApiException(message: String, cause: Throwable? = null) : + RuntimeException(message, cause) + +/** The server could not be reached at all (connection refused, timeout, DNS, ...). */ +class ArtemisServerUnreachableException(message: String, cause: Throwable? = null) : + ArtemisApiException(message, cause) + +/** The server answered with a non-2xx status code. */ +open class ArtemisHttpException( + val statusCode: Int, + val path: String, + responseBody: String?, +) : ArtemisApiException( + buildString { + append("HTTP ").append(statusCode).append(" from ").append(path) + val snippet = responseBody?.trim()?.take(200) + if (!snippet.isNullOrEmpty()) append(": ").append(snippet) + } +) + +/** The requested resource was not found (HTTP 404). */ +class ArtemisNotFoundException(path: String, responseBody: String?) : + ArtemisHttpException(404, path, responseBody) + +/** The scheduler refused to admit a task (status == "rejected" or an empty task list). */ +class ArtemisTaskRejectedException(detail: String) : + ArtemisApiException("Artemis host rejected the task: $detail") diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/Capabilities.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/Capabilities.kt new file mode 100644 index 00000000..f75823a6 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/Capabilities.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.gson.JsonObject + +/** + * Features advertised by an ARTEMIS host. + * Mirrors the Capabilities dataclass in packages/artemis-client (models.py). + */ +data class Capabilities( + val apiVersion: String, + val features: Set, + val serverVersion: String? = null, +) { + fun supports(feature: String): Boolean = feature in features + + companion object { + /** Baseline assumed for legacy servers without /api/v1/capabilities. */ + val LEGACY_FEATURES: Set = setOf( + "tasks.submit", + "tasks.get", + "tasks.stop", + "devices.list", + "system.readiness", + ) + + fun fromPayload(payload: JsonObject): Capabilities { + val rawFeatures = payload.get("features") + val features: Set = when { + rawFeatures == null || rawFeatures.isJsonNull -> emptySet() + rawFeatures.isJsonObject -> rawFeatures.asJsonObject.entrySet() + .filter { runCatching { it.value.asBoolean }.getOrDefault(false) } + .map { it.key } + .toSet() + rawFeatures.isJsonArray -> rawFeatures.asJsonArray + .mapNotNull { runCatching { it.asString }.getOrNull() } + .toSet() + else -> emptySet() + } + return Capabilities( + apiVersion = payload.firstString("api_version") ?: "unknown", + serverVersion = payload.firstString("server_version", "version"), + features = features, + ) + } + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/Device.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/Device.kt new file mode 100644 index 00000000..a99f8f38 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/Device.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.artemis.studio.api.ArtemisApiException +import com.google.gson.JsonObject + +/** + * A device reported by the remote ARTEMIS host. + * Mirrors the Device dataclass in packages/artemis-client (models.py). + */ +data class Device( + val serial: String, + val state: String, + val model: String? = null, + val product: String? = null, + val busy: Boolean = false, +) { + companion object { + private val BUSY_STATES = setOf("busy", "running", "locked") + + fun fromPayload(payload: JsonObject): Device { + val serial = payload.firstString("serial", "device_serial", "device_id") + ?: throw ArtemisProtocolException("Device response did not contain a serial number") + val state = (payload.firstString("state", "status") ?: "unknown").lowercase() + val busyFlag = payload.firstElement("busy", "is_busy") + ?.let { runCatching { it.asBoolean }.getOrNull() } ?: false + return Device( + serial = serial, + state = state, + model = payload.firstString("model"), + product = payload.firstString("product"), + busy = busyFlag || state in BUSY_STATES, + ) + } + } + + override fun toString(): String = buildString { + append(serial) + append(" (").append(state) + if (busy) append(", busy") + append(")") + } +} + +/** + * Thrown when the server response does not match the documented ARTEMIS payload + * shapes. Extends [ArtemisApiException] so malformed-payload failures are + * surfaced as readable UI errors by the same catch blocks that handle HTTP + * failures, instead of bubbling into the IDE error reporter. + */ +class ArtemisProtocolException(message: String) : ArtemisApiException(message) diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/JsonPayloads.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/JsonPayloads.kt new file mode 100644 index 00000000..67cd31c3 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/JsonPayloads.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.gson.JsonElement +import com.google.gson.JsonObject + +/** + * Task statuses considered terminal by the ARTEMIS client models. + * Mirrors TERMINAL_TASK_STATUSES in packages/artemis-client (models.py). + */ +val TERMINAL_TASK_STATUSES: Set = + setOf("completed", "success", "failed", "cancelled", "canceled", "rejected") + +/** Mirrors SUCCESS_TASK_STATUSES in packages/artemis-client (models.py). */ +val SUCCESS_TASK_STATUSES: Set = setOf("completed", "success") + +internal fun JsonObject.firstString(vararg keys: String): String? { + for (key in keys) { + val element = get(key) ?: continue + if (element.isJsonNull) continue + val text = runCatching { element.asString }.getOrNull()?.trim() + if (!text.isNullOrEmpty()) return text + } + return null +} + +internal fun JsonObject.firstInt(vararg keys: String): Int? { + for (key in keys) { + val element = get(key) ?: continue + if (element.isJsonNull) continue + val value = runCatching { element.asInt }.getOrNull() ?: continue + return value + } + return null +} + +internal fun JsonObject.firstElement(vararg keys: String): JsonElement? { + for (key in keys) { + val element = get(key) ?: continue + if (!element.isJsonNull) return element + } + return null +} + +/** Resolves a device serial from the many shapes the ARTEMIS server can emit. */ +internal fun deviceSerialFromPayload(payload: JsonObject): String? { + payload.firstString("device_serial", "device_id")?.let { return it } + var info = payload.get("device_info") ?: return null + // models.py also tolerates device_info arriving as a JSON-encoded string. + if (info.isJsonPrimitive && info.asJsonPrimitive.isString) { + info = try { + com.google.gson.JsonParser.parseString(info.asString) + } catch (e: Exception) { + return null + } + } + if (info.isJsonObject) { + return info.asJsonObject.firstString("device_serial", "device_id") + } + return null +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/RunRequest.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/RunRequest.kt new file mode 100644 index 00000000..35aa23d8 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/RunRequest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.gson.Gson +import com.google.gson.JsonObject +import java.util.UUID + +/** Verification levels accepted by /api/run (Pro-profile Checker presets). */ +val VERIFICATION_LEVELS: Set = setOf("off", "final", "checkpoints", "strict") + +/** Explorer perception versions accepted by /api/run (Pro-only tuning knob). */ +val EXPLORER_MODES: Set = setOf("flash", "pro", "ultra") + +/** + * Payload builder for POST /api/run. + * Mirrors ArtemisClient.submit() in packages/artemis-client (client.py): + * required keys are goal/profile/session_id/ingress; every other key is only + * sent when set. + */ +data class RunRequest( + val goal: String, + val profile: String, + val deviceSerial: String? = null, + val expectedOutput: String? = null, + val enableOutputter: Boolean? = null, + val lockedAppPackage: String? = null, + val appPath: String? = null, + val conversationId: String? = null, + val taskId: String? = null, + val verificationLevel: String? = null, + val explorerMode: String? = null, + val options: Map? = null, + val ingress: String = DEFAULT_INGRESS, +) { + init { + require(goal.isNotBlank()) { "goal must not be empty" } + require(profile in setOf("flash", "pro")) { "profile must be flash or pro; got $profile" } + verificationLevel?.let { + require(it in VERIFICATION_LEVELS) { + "verification_level must be one of ${VERIFICATION_LEVELS.joinToString()}; got $it" + } + } + explorerMode?.let { + require(it in EXPLORER_MODES) { + "explorer_mode must be one of ${EXPLORER_MODES.joinToString()}; got $it" + } + } + } + + /** Client-generated idempotency key sent as the legacy session_id. */ + val resolvedTaskId: String = taskId?.let { + try { + UUID.fromString(it).toString() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("task_id must be a valid UUID string", e) + } + } ?: UUID.randomUUID().toString() + + fun toJsonObject(): JsonObject { + val payload = JsonObject() + payload.addProperty("goal", goal.trim()) + payload.addProperty("profile", profile) + payload.addProperty("session_id", resolvedTaskId) + payload.addProperty("ingress", ingress) + deviceSerial?.let { payload.addProperty("device_serial", it) } + expectedOutput?.let { payload.addProperty("expected_output", it) } + enableOutputter?.let { payload.addProperty("enable_outputter", it) } + lockedAppPackage?.let { payload.addProperty("locked_app_package", it) } + appPath?.let { payload.addProperty("app_path", it) } + conversationId?.let { payload.addProperty("conversation_id", it) } + verificationLevel?.let { payload.addProperty("verification_level", it) } + explorerMode?.let { payload.addProperty("explorer_mode", it) } + options?.let { payload.add("options", GSON.toJsonTree(it)) } + return payload + } + + fun toJson(): String = toJsonObject().toString() + + companion object { + const val DEFAULT_INGRESS = "android_studio_plugin" + private val GSON = Gson() + + /** Strips + lower-cases an enumerated option, returning null for blank input. */ + fun normalizeChoice(value: String?, name: String, choices: Set): String? { + if (value == null) return null + val normalized = value.trim().lowercase() + if (normalized.isEmpty()) return null + require(normalized in choices) { + "$name must be one of ${choices.joinToString()}; got $value" + } + return normalized + } + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/TaskHandle.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/TaskHandle.kt new file mode 100644 index 00000000..f280c6b0 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/TaskHandle.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.gson.JsonObject + +/** + * A task accepted by the remote ARTEMIS scheduler. + * Mirrors the TaskHandle dataclass in packages/artemis-client (models.py). + */ +data class TaskHandle( + val taskId: String, + val status: String, + val deviceSerial: String? = null, +) { + /** Compatibility alias for servers that call a task a session. */ + val sessionId: String get() = taskId + + companion object { + fun fromPayload(payload: JsonObject): TaskHandle { + val taskId = payload.firstString("task_id", "session_id", "id") + ?: throw ArtemisProtocolException( + "Task admission response did not contain a task/session ID" + ) + return TaskHandle( + taskId = taskId, + status = (payload.firstString("status") ?: "queued").lowercase(), + deviceSerial = deviceSerialFromPayload(payload), + ) + } + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/TaskResult.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/TaskResult.kt new file mode 100644 index 00000000..d7789ccc --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/model/TaskResult.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.gson.JsonElement +import com.google.gson.JsonObject + +/** + * Current or terminal state of a remote ARTEMIS task. + * Mirrors the TaskResult dataclass in packages/artemis-client (models.py). + */ +data class TaskResult( + val taskId: String, + val status: String, + val goal: String? = null, + val profile: String? = null, + val deviceSerial: String? = null, + val output: JsonElement? = null, + val error: String? = null, + val turns: Int? = null, +) { + val sessionId: String get() = taskId + val done: Boolean get() = status in TERMINAL_TASK_STATUSES + val succeeded: Boolean get() = status in SUCCESS_TASK_STATUSES + + /** Human-readable one-line rendering of the output payload, if any. */ + val outputSummary: String? + get() = output?.let { if (it.isJsonPrimitive) it.asString else it.toString() } + + companion object { + fun fromPayload(payload: JsonObject, taskId: String? = null): TaskResult { + val resolvedId = payload.firstString("task_id", "session_id", "trace_id", "id") + ?: taskId + ?: throw ArtemisProtocolException("Task response did not contain a task/session ID") + return TaskResult( + taskId = resolvedId, + status = (payload.firstString("status") ?: "unknown").lowercase(), + goal = payload.firstString("goal", "initial_goal"), + profile = payload.firstString("profile"), + deviceSerial = deviceSerialFromPayload(payload), + output = payload.firstElement("output", "result", "summary"), + error = payload.firstString("error", "error_message"), + turns = payload.firstInt("turns", "current_turn"), + ) + } + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/services/ArtemisNotifications.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/services/ArtemisNotifications.kt new file mode 100644 index 00000000..b2c7e750 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/services/ArtemisNotifications.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.services + +import com.google.artemis.studio.model.TaskResult +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project + +/** Balloon notifications for ARTEMIS task lifecycle events. */ +object ArtemisNotifications { + const val GROUP_ID = "ARTEMIS_TASKS" + + fun notifyTaskFinished(project: Project, result: TaskResult) { + val type: NotificationType + val title: String + when { + result.succeeded -> { + type = NotificationType.INFORMATION + title = "ARTEMIS task completed" + } + result.status == "cancelled" || result.status == "canceled" -> { + type = NotificationType.WARNING + title = "ARTEMIS task cancelled" + } + else -> { + type = NotificationType.ERROR + title = "ARTEMIS task failed" + } + } + val content = buildString { + append(result.goal ?: result.taskId) + result.outputSummary?.let { append(" — ").append(it.take(200)) } + result.error?.let { append(" — ").append(it.take(200)) } + } + NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification(title, content, type) + .notify(project) + } + + fun notifyError(project: Project, title: String, detail: String) { + NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification(title, detail.take(300), NotificationType.ERROR) + .notify(project) + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/services/TaskPollingService.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/services/TaskPollingService.kt new file mode 100644 index 00000000..31f45581 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/services/TaskPollingService.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.services + +import com.google.artemis.studio.api.ArtemisApiClient +import com.google.artemis.studio.api.ArtemisApiException +import com.google.artemis.studio.model.Device +import com.google.artemis.studio.model.TaskHandle +import com.google.artemis.studio.model.TaskResult +import com.google.artemis.studio.settings.ArtemisSettings +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Project-level service that tracks this session's submitted ARTEMIS tasks and + * polls the server for their status. All network I/O happens on background + * coroutines; listener callbacks are always delivered on the Swing EDT. + */ +@Service(Service.Level.PROJECT) +class TaskPollingService(private val project: Project, private val scope: CoroutineScope) { + + /** One task submitted from this IDE session. */ + class TrackedTask( + val taskId: String, + val goal: String, + val profile: String, + val deviceSerial: String?, + @Volatile var latest: TaskResult? = null, + ) { + val status: String get() = latest?.status ?: "queued" + val done: Boolean get() = latest?.done ?: false + + override fun toString(): String = "[$status] $goal" + } + + /** UI callbacks. Every method is invoked on the EDT. */ + interface Listener { + /** The tracked-task list or one of its entries changed. */ + fun onTasksUpdated() + + /** Server reachability was determined for the first time or changed. */ + fun onServerStatusChanged(reachable: Boolean, detail: String) + + /** A device refresh completed; [error] is non-null on failure. */ + fun onDevicesChanged(devices: List, error: String?) + } + + private val tracked = CopyOnWriteArrayList() + private val listeners = CopyOnWriteArrayList() + private val pollLock = Any() + private val pollCycleLock = Any() + private var pollJob: Job? = null + + @Volatile + var serverReachable: Boolean = false + private set + + @Volatile + private var serverStatusReported: Boolean = false + + fun trackedTasks(): List = tracked.toList() + + fun addListener(listener: Listener) { + listeners.addIfAbsent(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + /** Register a freshly admitted task and start polling for its status. */ + fun track(handle: TaskHandle, goal: String, profile: String) { + tracked.add(TrackedTask(handle.taskId, goal, profile, handle.deviceSerial)) + notifyTasksUpdated() + startPolling() + pollOnceAsync() + } + + /** Ask the server to cancel a task, then refresh its status. */ + fun requestStop(taskId: String) { + scope.launch(Dispatchers.IO) { + try { + newClient().stop(taskId) + } catch (e: ArtemisApiException) { + notifyEdt { + ArtemisNotifications.notifyError(project, "ARTEMIS stop failed", e.message ?: "") + } + } + pollOnce() + } + } + + /** Fetch the device list once, off the EDT. */ + fun refreshDevices() { + scope.launch(Dispatchers.IO) { + try { + val devices = newClient().listDevices() + notifyEdt { listeners.forEach { it.onDevicesChanged(devices, null) } } + } catch (e: ArtemisApiException) { + notifyEdt { + listeners.forEach { it.onDevicesChanged(emptyList(), e.message ?: "request failed") } + } + } + } + } + + /** + * Start the 2-second polling loop (idempotent). The loop exits on its own + * once every tracked task is terminal, and is cancelled when the project + * (and therefore this service's scope) is disposed. + */ + fun startPolling() { + synchronized(pollLock) { + if (pollJob?.isActive == true) return + pollJob = scope.launch(Dispatchers.IO) { + while (isActive) { + pollOnce() + // Stop polling when nothing is left to poll. The check and + // the pollJob reset happen under pollLock so a concurrent + // track() + startPolling() either sees this job as still + // active (and the loop then sees the new task) or sees + // pollJob == null and starts a fresh loop. + val keepPolling = synchronized(pollLock) { + if (tracked.any { !it.done }) { + true + } else { + pollJob = null + false + } + } + if (!keepPolling) break + delay(POLL_INTERVAL_MS) + } + } + } + } + + /** Run one polling cycle in the background (health + active task statuses). */ + fun pollOnceAsync() { + scope.launch(Dispatchers.IO) { pollOnce() } + } + + // Serialized with pollCycleLock so the periodic loop, manual refreshes and + // stop-follow-up polls never run concurrently; that keeps the + // read-modify-write of TrackedTask.latest (and the "finished" notification + // decision based on it) race-free. + private fun pollOnce() = synchronized(pollCycleLock) { + val client = newClient() + var reachable = true + var detail = "Connected to ${ArtemisSettings.getInstance().serverUrl}" + try { + client.health() + } catch (e: ArtemisApiException) { + reachable = false + detail = e.message ?: "Server unreachable" + } + val changed = reachable != serverReachable + serverReachable = reachable + // Always report the first poll outcome (the UI starts in a "Checking…" + // state), then only on transitions — repeating the same unreachable + // state every cycle would just churn the EDT. + if (changed || !serverStatusReported) { + serverStatusReported = true + val d = detail + val r = reachable + notifyEdt { listeners.forEach { it.onServerStatusChanged(r, d) } } + } + if (!reachable) return@synchronized + + var anyUpdated = false + for (task in tracked) { + if (task.done) continue + try { + val result = client.getTask(task.taskId) + val wasDone = task.latest?.done ?: false + task.latest = result + anyUpdated = true + if (result.done && !wasDone) { + notifyEdt { ArtemisNotifications.notifyTaskFinished(project, result) } + } + } catch (e: ArtemisApiException) { + // Transient poll failures keep the previous state; the health check + // above already reports a fully unreachable server. + if (task.latest == null) { + task.latest = TaskResult(task.taskId, "unknown", goal = task.goal, error = e.message) + anyUpdated = true + } + } + } + if (anyUpdated) notifyTasksUpdated() + } + + private fun notifyTasksUpdated() { + notifyEdt { listeners.forEach { it.onTasksUpdated() } } + } + + private fun newClient(): ArtemisApiClient = + ArtemisApiClient(ArtemisSettings.getInstance().serverUrl) + + private fun notifyEdt(action: () -> Unit) { + val application = ApplicationManager.getApplication() ?: return + application.invokeLater( + { if (!project.isDisposed) action() }, + ModalityState.any(), + project.disposed, + ) + } + + companion object { + const val POLL_INTERVAL_MS = 2_000L + + fun getInstance(project: Project): TaskPollingService = + project.getService(TaskPollingService::class.java) + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/settings/ArtemisConfigurable.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/settings/ArtemisConfigurable.kt new file mode 100644 index 00000000..a70d24fe --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/settings/ArtemisConfigurable.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.settings + +import com.intellij.openapi.options.Configurable +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.FormBuilder +import javax.swing.JComboBox +import javax.swing.JComponent +import javax.swing.JPanel + +/** Settings page registered under Settings → Tools → ARTEMIS. */ +class ArtemisConfigurable : Configurable { + + private val serverUrlField = JBTextField() + private val profileCombo = JComboBox(arrayOf("flash", "pro")) + private val deviceSerialField = JBTextField() + private var panel: JPanel? = null + + override fun getDisplayName(): String = "ARTEMIS" + + override fun createComponent(): JComponent { + val form = FormBuilder.createFormBuilder() + .addLabeledComponent("Server base URL:", serverUrlField) + .addTooltip("Base URL of a running ARTEMIS server, e.g. http://localhost:8000") + .addLabeledComponent("Default profile:", profileCombo) + .addTooltip("Agent profile used for new tasks: flash (fast) or pro (thorough)") + .addLabeledComponent("Default device serial:", deviceSerialField) + .addTooltip("Optional adb serial used when no device is selected in the tool window") + .addComponentFillVertically(JPanel(), 0) + .panel + panel = form + reset() + return form + } + + override fun isModified(): Boolean { + val settings = ArtemisSettings.getInstance() + return serverUrlField.text.trim().trimEnd('/') != settings.serverUrl || + profileCombo.selectedItem as? String != settings.defaultProfile || + deviceSerialField.text.trim() != settings.defaultDeviceSerial + } + + override fun apply() { + val settings = ArtemisSettings.getInstance() + settings.serverUrl = serverUrlField.text + settings.defaultProfile = profileCombo.selectedItem as? String ?: ArtemisSettings.DEFAULT_PROFILE + settings.defaultDeviceSerial = deviceSerialField.text + } + + override fun reset() { + val settings = ArtemisSettings.getInstance() + serverUrlField.text = settings.serverUrl + profileCombo.selectedItem = settings.defaultProfile + deviceSerialField.text = settings.defaultDeviceSerial + } + + override fun disposeUIResources() { + panel = null + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/settings/ArtemisSettings.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/settings/ArtemisSettings.kt new file mode 100644 index 00000000..2191bd35 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/settings/ArtemisSettings.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage + +/** Application-level persisted settings for the ARTEMIS thin client. */ +@Service(Service.Level.APP) +@State(name = "ArtemisSettings", storages = [Storage("artemis.xml")]) +class ArtemisSettings : PersistentStateComponent { + + class State { + var serverUrl: String = DEFAULT_SERVER_URL + var defaultProfile: String = DEFAULT_PROFILE + var defaultDeviceSerial: String = "" + } + + private var state = State() + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + var serverUrl: String + get() = state.serverUrl.ifBlank { DEFAULT_SERVER_URL } + set(value) { + state.serverUrl = value.trim().trimEnd('/') + } + + var defaultProfile: String + get() = state.defaultProfile.ifBlank { DEFAULT_PROFILE } + set(value) { + state.defaultProfile = value + } + + var defaultDeviceSerial: String + get() = state.defaultDeviceSerial + set(value) { + state.defaultDeviceSerial = value.trim() + } + + companion object { + const val DEFAULT_SERVER_URL = "http://localhost:8000" + const val DEFAULT_PROFILE = "flash" + + fun getInstance(): ArtemisSettings = + ApplicationManager.getApplication().getService(ArtemisSettings::class.java) + } +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/toolwindow/ArtemisToolWindowFactory.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/toolwindow/ArtemisToolWindowFactory.kt new file mode 100644 index 00000000..d19fc4fd --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/toolwindow/ArtemisToolWindowFactory.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.toolwindow + +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory + +/** Registers the ARTEMIS tool window (anchored right). */ +class ArtemisToolWindowFactory : ToolWindowFactory, DumbAware { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val panel = ArtemisToolWindowPanel(project) + val content = ContentFactory.getInstance().createContent(panel, "", false) + content.setDisposer(Disposer.newDisposable("ArtemisToolWindow").also { d -> + Disposer.register(d, panel) + }) + toolWindow.contentManager.addContent(content) + } + + override fun shouldBeAvailable(project: Project): Boolean = true +} diff --git a/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/toolwindow/ArtemisToolWindowPanel.kt b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/toolwindow/ArtemisToolWindowPanel.kt new file mode 100644 index 00000000..89575995 --- /dev/null +++ b/android-studio-plugin/src/main/kotlin/com/google/artemis/studio/toolwindow/ArtemisToolWindowPanel.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.toolwindow + +import com.google.artemis.studio.api.ArtemisApiClient +import com.google.artemis.studio.api.ArtemisApiException +import com.google.artemis.studio.api.ArtemisTaskRejectedException +import com.google.artemis.studio.model.Device +import com.google.artemis.studio.model.RunRequest +import com.google.artemis.studio.services.ArtemisNotifications +import com.google.artemis.studio.services.TaskPollingService +import com.google.artemis.studio.settings.ArtemisSettings +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.DefaultListModel +import javax.swing.JButton +import javax.swing.JComboBox +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +/** + * Main panel of the ARTEMIS tool window: server status, device picker, prompt + * submission, and the session task list with live status and a detail pane. + * + * All network calls run on pooled background threads; Swing state is only + * touched on the EDT. + */ +class ArtemisToolWindowPanel(private val project: Project) : + JPanel(BorderLayout()), TaskPollingService.Listener, Disposable { + + /** Combo entry: either a concrete device or "use the configured default". */ + private class DeviceChoice(val device: Device?) { + override fun toString(): String = device?.toString() ?: "Default (from settings)" + } + + private val service: TaskPollingService = TaskPollingService.getInstance(project) + + private val statusLabel = JBLabel("Checking ARTEMIS server…") + private val refreshButton = JButton("Refresh", AllIcons.Actions.Refresh) + private val deviceCombo = JComboBox() + private val promptArea = JBTextArea(4, 24) + private val profileCombo = JComboBox(arrayOf("flash", "pro")) + private val runButton = JButton("Run Task", AllIcons.Actions.Execute) + private val taskListModel = DefaultListModel() + private val taskList = JBList(taskListModel) + private val detailArea = JBTextArea(8, 24) + private val stopButton = JButton("Stop", AllIcons.Actions.Suspend) + + private var serverReachable = false + + init { + border = JBUI.Borders.empty(8) + + promptArea.lineWrap = true + promptArea.wrapStyleWord = true + promptArea.emptyText.text = "Describe the on-device task, e.g. \"Open Settings and enable dark mode\"" + detailArea.isEditable = false + detailArea.lineWrap = true + detailArea.wrapStyleWord = true + taskList.selectionMode = ListSelectionModel.SINGLE_SELECTION + stopButton.isEnabled = false + + layoutComponents() + wireActions() + + service.addListener(this) + rebuildTaskList() + updateRunButtonState() + // Initial server/device discovery, off the EDT. + service.pollOnceAsync() + service.refreshDevices() + } + + private fun layoutComponents() { + val content = JPanel(GridBagLayout()) + val gc = GridBagConstraints().apply { + gridx = 0 + gridy = 0 + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + insets = JBUI.insets(2) + } + + val statusRow = JPanel(BorderLayout(JBUI.scale(4), 0)) + statusRow.add(statusLabel, BorderLayout.CENTER) + statusRow.add(refreshButton, BorderLayout.EAST) + content.add(statusRow, gc) + + val deviceRow = JPanel(BorderLayout(JBUI.scale(4), 0)) + deviceRow.add(JBLabel("Device:"), BorderLayout.WEST) + deviceRow.add(deviceCombo, BorderLayout.CENTER) + gc.gridy++ + content.add(deviceRow, gc) + + gc.gridy++ + content.add(JBLabel("Prompt:"), gc) + + gc.gridy++ + content.add(JBScrollPane(promptArea), gc) + + val runRow = JPanel(BorderLayout(JBUI.scale(4), 0)) + val profileRow = JPanel(BorderLayout(JBUI.scale(4), 0)) + profileRow.add(JBLabel("Profile:"), BorderLayout.WEST) + profileRow.add(profileCombo, BorderLayout.CENTER) + runRow.add(profileRow, BorderLayout.CENTER) + runRow.add(runButton, BorderLayout.EAST) + gc.gridy++ + content.add(runRow, gc) + + gc.gridy++ + content.add(JBLabel("Tasks (this session):"), gc) + + gc.gridy++ + gc.weighty = 0.6 + gc.fill = GridBagConstraints.BOTH + content.add(JBScrollPane(taskList).apply { preferredSize = JBUI.size(220, 120) }, gc) + + gc.gridy++ + gc.weighty = 0.0 + gc.fill = GridBagConstraints.HORIZONTAL + content.add(JBLabel("Task details:"), gc) + + gc.gridy++ + gc.weighty = 0.4 + gc.fill = GridBagConstraints.BOTH + val detailScroll: JScrollPane = JBScrollPane(detailArea) + content.add(detailScroll, gc) + + gc.gridy++ + gc.weighty = 0.0 + gc.fill = GridBagConstraints.HORIZONTAL + gc.anchor = GridBagConstraints.EAST + content.add(stopButton, gc) + + add(content, BorderLayout.CENTER) + } + + private fun wireActions() { + refreshButton.addActionListener { + service.pollOnceAsync() + service.refreshDevices() + } + + runButton.addActionListener { submitPrompt() } + + stopButton.addActionListener { + val selected = taskList.selectedValue ?: return@addActionListener + stopButton.isEnabled = false + service.requestStop(selected.taskId) + } + + taskList.addListSelectionListener { + if (!it.valueIsAdjusting) updateDetailPane() + } + + promptArea.document.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent) = updateRunButtonState() + override fun removeUpdate(e: DocumentEvent) = updateRunButtonState() + override fun changedUpdate(e: DocumentEvent) = updateRunButtonState() + }) + + // Seed defaults from settings. + profileCombo.selectedItem = ArtemisSettings.getInstance().defaultProfile + deviceCombo.addItem(DeviceChoice(null)) + } + + private fun submitPrompt() { + val goal = promptArea.text.trim() + if (goal.isEmpty()) return + val profile = profileCombo.selectedItem as? String ?: ArtemisSettings.DEFAULT_PROFILE + val selectedDevice = (deviceCombo.selectedItem as? DeviceChoice)?.device?.serial + ?: ArtemisSettings.getInstance().defaultDeviceSerial.ifBlank { null } + + val request = try { + RunRequest( + goal = goal, + profile = profile, + deviceSerial = selectedDevice, + ) + } catch (e: IllegalArgumentException) { + ArtemisNotifications.notifyError(project, "Invalid ARTEMIS request", e.message ?: "") + return + } + + runButton.isEnabled = false + val client = ArtemisApiClient(ArtemisSettings.getInstance().serverUrl) + ApplicationManager.getApplication().executeOnPooledThread { + try { + val handle = client.submit(request) + onEdt { + service.track(handle, request.goal.trim(), request.profile) + promptArea.text = "" + } + } catch (e: ArtemisTaskRejectedException) { + onEdt { + ArtemisNotifications.notifyError(project, "ARTEMIS task rejected", e.message ?: "") + } + } catch (e: ArtemisApiException) { + onEdt { + ArtemisNotifications.notifyError(project, "ARTEMIS submission failed", e.message ?: "") + } + } finally { + onEdt { updateRunButtonState() } + } + } + } + + // ---- TaskPollingService.Listener (always called on the EDT) ---- + + override fun onTasksUpdated() { + rebuildTaskList() + updateDetailPane() + } + + override fun onServerStatusChanged(reachable: Boolean, detail: String) { + serverReachable = reachable + if (reachable) { + statusLabel.text = "Server: reachable" + statusLabel.icon = AllIcons.General.InspectionsOK + statusLabel.toolTipText = null + } else { + statusLabel.text = "Server: unreachable" + statusLabel.icon = AllIcons.General.Error + statusLabel.toolTipText = detail + } + updateRunButtonState() + } + + override fun onDevicesChanged(devices: List, error: String?) { + val previousSerial = (deviceCombo.selectedItem as? DeviceChoice)?.device?.serial + deviceCombo.removeAllItems() + deviceCombo.addItem(DeviceChoice(null)) + val defaultSerial = ArtemisSettings.getInstance().defaultDeviceSerial + var toSelect = 0 + devices.forEachIndexed { index, device -> + deviceCombo.addItem(DeviceChoice(device)) + val wanted = previousSerial ?: defaultSerial + if (wanted.isNotBlank() && device.serial == wanted) toSelect = index + 1 + } + deviceCombo.selectedIndex = toSelect + if (error != null) { + statusLabel.toolTipText = "Device refresh failed: $error" + } + } + + // ---- Internals (EDT only) ---- + + private fun rebuildTaskList() { + val selectedId = taskList.selectedValue?.taskId + taskListModel.clear() + var reselect = -1 + service.trackedTasks().forEachIndexed { index, task -> + taskListModel.addElement(task) + if (task.taskId == selectedId) reselect = index + } + if (reselect >= 0) { + taskList.selectedIndex = reselect + } else if (taskListModel.size() > 0 && selectedId == null) { + taskList.selectedIndex = taskListModel.size() - 1 + } + } + + private fun updateDetailPane() { + val task = taskList.selectedValue + if (task == null) { + detailArea.text = "No task selected." + stopButton.isEnabled = false + return + } + val result = task.latest + detailArea.text = buildString { + append("Task: ").append(task.taskId).append('\n') + append("Goal: ").append(task.goal).append('\n') + append("Profile: ").append(task.profile).append('\n') + append("Device: ").append(task.deviceSerial ?: "server default").append('\n') + append("Status: ").append(task.status).append('\n') + result?.turns?.let { append("Turns: ").append(it).append('\n') } + result?.outputSummary?.let { append("Result: ").append(it).append('\n') } + result?.error?.let { append("Error: ").append(it).append('\n') } + } + detailArea.caretPosition = 0 + stopButton.isEnabled = !task.done + } + + private fun updateRunButtonState() { + runButton.isEnabled = serverReachable && promptArea.text.isNotBlank() + } + + private fun onEdt(action: () -> Unit) { + ApplicationManager.getApplication().invokeLater( + { if (!project.isDisposed) action() }, + ModalityState.any(), + project.disposed, + ) + } + + override fun dispose() { + service.removeListener(this) + } +} diff --git a/android-studio-plugin/src/main/resources/META-INF/plugin.xml b/android-studio-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 00000000..ea6b5630 --- /dev/null +++ b/android-studio-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,49 @@ + + + + com.google.artemis.studio + ARTEMIS + Google + + + + com.intellij.modules.platform + + + + + + + + + + + + diff --git a/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/api/ArtemisApiClientIntegrationTest.kt b/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/api/ArtemisApiClientIntegrationTest.kt new file mode 100644 index 00000000..80916f9e --- /dev/null +++ b/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/api/ArtemisApiClientIntegrationTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.api + +import com.google.artemis.studio.model.RunRequest +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Integration tests of [ArtemisApiClient] against the JDK's built-in + * [HttpServer], returning canned responses that mirror the fixtures in + * packages/artemis-client/tests/test_client.py. + */ +class ArtemisApiClientIntegrationTest { + + private data class RecordedRequest(val method: String, val path: String, val body: String) + + private lateinit var server: HttpServer + private lateinit var client: ArtemisApiClient + private val recorded = CopyOnWriteArrayList() + + @BeforeEach + fun setUp() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.start() + client = ArtemisApiClient("http://127.0.0.1:${server.address.port}") + } + + @AfterEach + fun tearDown() { + server.stop(0) + } + + private fun respond(path: String, bodyProvider: (RecordedRequest) -> Pair) { + server.createContext(path) { exchange: HttpExchange -> + val requestBody = exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8) + val recordedRequest = + RecordedRequest(exchange.requestMethod, exchange.requestURI.path, requestBody) + recorded.add(recordedRequest) + val (code, body) = bodyProvider(recordedRequest) + val bytes = body.toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(code, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + } + + @Test + fun `submit poll and stop happy path`() { + val taskId = "00000000-0000-4000-8000-000000000123" + respond("/api/run") { + 200 to """{"status": "started", "tasks": [{"session_id": "$taskId", "status": "pending", "device_serial": "pixel-8"}]}""" + } + respond("/api/sessions/$taskId") { + 200 to """{"session_id": "$taskId", "status": "completed", "goal": "Open Settings", "current_turn": 4, "summary": "Battery page opened"}""" + } + respond("/api/stop") { 200 to """{"status": "stopped"}""" } + + val request = RunRequest( + goal = "Open Settings", + profile = "flash", + taskId = taskId, + deviceSerial = "pixel-8", + options = mapOf("record_video" to true), + ) + val handle = client.submit(request) + assertEquals(taskId, handle.taskId) + assertEquals("pending", handle.status) + assertEquals("pixel-8", handle.deviceSerial) + + // The wire payload must match the Python client's submit() shape. + val runBody = recorded.single { it.path == "/api/run" }.body.asJson() + assertEquals(taskId, runBody.get("session_id").asString) + assertEquals("Open Settings", runBody.get("goal").asString) + assertEquals("android_studio_plugin", runBody.get("ingress").asString) + assertTrue(runBody.getAsJsonObject("options").get("record_video").asBoolean) + + val result = client.getTask(taskId) + assertTrue(result.done) + assertTrue(result.succeeded) + assertEquals(4, result.turns) + assertEquals("Battery page opened", result.output!!.asString) + + assertTrue(client.stop(taskId)) + val stopBody = recorded.single { it.path == "/api/stop" }.body.asJson() + assertEquals(taskId, stopBody.get("session_id").asString) + } + + @Test + fun `rejected submission raises task rejected with server detail`() { + // Mirrors test_submit_rejected_task_raises_specific_error. + respond("/api/run") { 200 to """{"status": "rejected", "error": "Device is offline", "tasks": []}""" } + val error = assertThrows { + client.submit(RunRequest(goal = "Open Settings", profile = "flash")) + } + assertTrue(error.message!!.contains("Device is offline")) + } + + @Test + fun `devices endpoint accepts legacy wrapped shape`() { + // Mirrors test_list_devices_accepts_legacy_shape. + respond("/api/devices") { + 200 to """{"devices": [{"serial": "emulator-5554", "state": "device", "model": "Pixel_8", "busy": true}]}""" + } + val devices = client.listDevices() + assertEquals(1, devices.size) + assertEquals("emulator-5554", devices[0].serial) + assertTrue(devices[0].busy) + } + + @Test + fun `getTask falls back to scheduler queue then launching`() { + // Mirrors test_run_finds_queued_task_then_reads_terminal_session (404 branch) + // and test_get_task_returns_launching_when_not_visible_yet. + val queuedId = "00000000-0000-4000-8000-000000000124" + respond("/api/sessions/$queuedId") { 404 to """{"detail": "not created yet"}""" } + respond("/api/sessions/new-task") { 404 to """{"detail": "missing"}""" } + respond("/api/status") { + 200 to """{"status": "running", "queue": [{"session_id": "$queuedId", "status": "pending"}]}""" + } + + val queued = client.getTask(queuedId) + assertEquals("pending", queued.status) + assertFalse(queued.done) + + val launching = client.getTask("new-task") + assertEquals("launching", launching.status) + assertFalse(launching.done) + } + + @Test + fun `capabilities fall back to legacy baseline on 404`() { + respond("/api/v1/capabilities") { 404 to """{"detail": "not implemented"}""" } + val caps = client.capabilities() + assertEquals("legacy", caps.apiVersion) + assertTrue(caps.supports("tasks.submit")) + assertTrue(caps.supports("devices.list")) + } + + @Test + fun `http 500 surfaces as readable api exception`() { + respond("/api/status") { 500 to """{"detail": "boom"}""" } + val error = assertThrows { client.health() } + assertEquals(500, error.statusCode) + assertTrue(error.message!!.contains("HTTP 500")) + assertTrue(error.message!!.contains("/api/status")) + } + + @Test + fun `unreachable server surfaces as unreachable exception`() { + server.stop(0) + val error = assertThrows { client.health() } + assertTrue(error.message!!.contains("Cannot reach ARTEMIS server")) + } + + @Test + fun `invalid devices payload is rejected`() { + // Mirrors test_invalid_devices_payload_is_rejected. + respond("/api/devices") { 200 to """{"devices": "not-a-list"}""" } + assertThrows { client.listDevices() } + } + + @Test + fun `devices endpoint treats missing devices key as empty list`() { + // Mirrors client.py: payload.get("devices", []) yields [] when the key + // is absent, but an explicit null remains a protocol error. + respond("/api/devices") { 200 to """{}""" } + assertTrue(client.listDevices().isEmpty()) + } + + @Test + fun `submit injects client session id when server returns only id`() { + // Matches client.py setdefault("session_id", resolved_task_id): the + // client-generated idempotency ID wins over a bare server-side "id". + respond("/api/run") { 200 to """{"status": "started", "tasks": [{"id": "server-internal-1"}]}""" } + val handle = client.submit(RunRequest(goal = "Open Settings", profile = "flash")) + // The handle must NOT adopt the server-internal id as the task id. + assertTrue(handle.taskId != "server-internal-1") + UUID.fromString(handle.taskId) // client-generated UUID + } + + @Test + fun `malformed admitted task entry raises api exception not runtime error`() { + // The entry has an empty session_id, which firstString treats as absent; + // TaskHandle parsing then fails with ArtemisProtocolException, which must + // be an ArtemisApiException so UI catch blocks surface it as a readable + // error instead of leaking into the IDE error reporter. + respond("/api/run") { 200 to """{"status": "started", "tasks": [{"session_id": " ", "status": "pending"}]}""" } + assertThrows { + client.submit(RunRequest(goal = "Open Settings", profile = "flash")) + } + } + + private fun String.asJson(): JsonObject = JsonParser.parseString(this).asJsonObject +} diff --git a/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/model/ModelParsingTest.kt b/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/model/ModelParsingTest.kt new file mode 100644 index 00000000..1a3a4626 --- /dev/null +++ b/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/model/ModelParsingTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import com.google.gson.JsonParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Parsing tests with canned payloads mirrored from + * packages/artemis-client/tests/test_client.py. + */ +class ModelParsingTest { + + private fun parse(json: String) = JsonParser.parseString(json).asJsonObject + + @Test + fun `device list legacy shape parses`() { + // Mirrors test_list_devices_accepts_legacy_shape. + val payload = parse( + """{"serial": "emulator-5554", "state": "device", "model": "Pixel_8", "busy": true}""" + ) + val device = Device.fromPayload(payload) + assertEquals("emulator-5554", device.serial) + assertEquals("device", device.state) + assertEquals("Pixel_8", device.model) + assertTrue(device.busy) + } + + @Test + fun `device busy is inferred from busy states`() { + val device = Device.fromPayload(parse("""{"device_id": "pixel-8", "status": "RUNNING"}""")) + assertEquals("pixel-8", device.serial) + assertEquals("running", device.state) + assertTrue(device.busy) + } + + @Test + fun `device without serial is rejected`() { + val error = org.junit.jupiter.api.assertThrows { + Device.fromPayload(parse("""{"state": "device"}""")) + } + assertTrue(error.message!!.contains("serial")) + } + + @Test + fun `task handle parses admission response entry`() { + // Mirrors the /api/run task entry in test_submit_sends_idempotent_session_id. + val taskId = "00000000-0000-4000-8000-000000000123" + val handle = TaskHandle.fromPayload( + parse("""{"session_id": "$taskId", "status": "PENDING", "device_serial": "pixel-8"}""") + ) + assertEquals(taskId, handle.taskId) + assertEquals("pending", handle.status) + assertEquals("pixel-8", handle.deviceSerial) + assertEquals(taskId, handle.sessionId) + } + + @Test + fun `task result parses terminal session payload`() { + // Mirrors the terminal /api/sessions payload in + // test_run_finds_queued_task_then_reads_terminal_session. + val taskId = "00000000-0000-4000-8000-000000000124" + val result = TaskResult.fromPayload( + parse( + """{ + "session_id": "$taskId", + "status": "completed", + "goal": "Open Settings", + "current_turn": 4, + "summary": "Battery page opened" + }""" + ) + ) + assertTrue(result.done) + assertTrue(result.succeeded) + assertEquals(4, result.turns) + assertEquals("Battery page opened", result.output!!.asString) + assertEquals("Open Settings", result.goal) + } + + @Test + fun `task result falls back to alternative field names`() { + val result = TaskResult.fromPayload( + parse( + """{ + "trace_id": "abc-123", + "status": "FAILED", + "initial_goal": "Send a message", + "error_message": "device went offline", + "device_info": {"device_serial": "emu-1"} + }""" + ) + ) + assertEquals("abc-123", result.taskId) + assertEquals("failed", result.status) + assertTrue(result.done) + assertFalse(result.succeeded) + assertEquals("Send a message", result.goal) + assertEquals("device went offline", result.error) + assertEquals("emu-1", result.deviceSerial) + assertNull(result.turns) + } + + @Test + fun `task result uses explicit task id fallback`() { + val result = TaskResult.fromPayload(parse("""{"status": "launching"}"""), "new-task") + assertEquals("new-task", result.taskId) + assertEquals("launching", result.status) + assertFalse(result.done) + } + + @Test + fun `device serial resolves from json-encoded device_info string`() { + // Mirrors models.py _device_from_payload, which json.loads a string + // device_info before reading device_serial from it. + val result = TaskResult.fromPayload( + parse( + """{ + "session_id": "abc-123", + "status": "running", + "device_info": "{\"device_serial\": \"emu-string-1\"}" + }""" + ) + ) + assertEquals("emu-string-1", result.deviceSerial) + } + + @Test + fun `protocol exception is an api exception subtype`() { + // UI catch blocks only handle ArtemisApiException; malformed payloads + // must not escape as an unrelated RuntimeException into the IDE. + org.junit.jupiter.api.assertThrows { + Device.fromPayload(parse("""{"state": "device"}""")) + } + } + + @Test + fun `capabilities parse list-shaped features`() { + val caps = Capabilities.fromPayload( + parse( + """{ + "api_version": "v1", + "server_version": "0.4.0", + "features": ["tasks.submit", "devices.list"] + }""" + ) + ) + assertEquals("v1", caps.apiVersion) + assertEquals("0.4.0", caps.serverVersion) + assertTrue(caps.supports("tasks.submit")) + assertFalse(caps.supports("tasks.stop")) + } + + @Test + fun `capabilities parse map-shaped features keeping only enabled ones`() { + val caps = Capabilities.fromPayload( + parse( + """{ + "api_version": "v1", + "features": {"tasks.submit": true, "tasks.stop": false} + }""" + ) + ) + assertTrue(caps.supports("tasks.submit")) + assertFalse(caps.supports("tasks.stop")) + assertEquals("unknown", Capabilities.fromPayload(parse("""{}""")).apiVersion) + } +} diff --git a/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/model/RunRequestTest.kt b/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/model/RunRequestTest.kt new file mode 100644 index 00000000..323e89ef --- /dev/null +++ b/android-studio-plugin/src/test/kotlin/com/google/artemis/studio/model/RunRequestTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.artemis.studio.model + +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * POST /api/run payload building tests, mirroring the submit() contract in + * packages/artemis-client (client.py). + */ +class RunRequestTest { + + @Test + fun `payload contains required fields and idempotent session id`() { + // Mirrors test_submit_sends_idempotent_session_id. + val taskId = "00000000-0000-4000-8000-000000000123" + val payload = RunRequest( + goal = "Open Settings", + profile = "flash", + taskId = taskId, + deviceSerial = "pixel-8", + options = mapOf("record_video" to true), + ).toJsonObject() + + assertEquals("Open Settings", payload.get("goal").asString) + assertEquals("flash", payload.get("profile").asString) + assertEquals(taskId, payload.get("session_id").asString) + assertEquals("android_studio_plugin", payload.get("ingress").asString) + assertEquals("pixel-8", payload.get("device_serial").asString) + assertTrue(payload.getAsJsonObject("options").get("record_video").asBoolean) + } + + @Test + fun `session id is generated as uuid when omitted`() { + val request = RunRequest(goal = "Open Settings", profile = "flash") + UUID.fromString(request.resolvedTaskId) // throws unless valid + assertEquals(request.resolvedTaskId, request.toJsonObject().get("session_id").asString) + } + + @Test + fun `normalized pro tuning knobs are forwarded`() { + // Mirrors test_submit_forwards_pro_tuning_knobs_normalised. + val payload = RunRequest( + goal = "Audit checkout", + profile = "pro", + verificationLevel = RunRequest.normalizeChoice(" Strict ", "verification_level", VERIFICATION_LEVELS), + explorerMode = RunRequest.normalizeChoice("ULTRA", "explorer_mode", EXPLORER_MODES), + ).toJsonObject() + assertEquals("strict", payload.get("verification_level").asString) + assertEquals("ultra", payload.get("explorer_mode").asString) + } + + @Test + fun `pro tuning knobs are omitted when unset or blank`() { + // Mirrors test_submit_omits_pro_tuning_knobs_when_unset. + val payload = RunRequest( + goal = "Open Settings", + profile = "flash", + verificationLevel = RunRequest.normalizeChoice(null, "verification_level", VERIFICATION_LEVELS), + explorerMode = RunRequest.normalizeChoice(" ", "explorer_mode", EXPLORER_MODES), + ).toJsonObject() + assertFalse(payload.has("verification_level")) + assertFalse(payload.has("explorer_mode")) + } + + @Test + fun `unknown tuning values are rejected before any request`() { + // Mirrors test_submit_rejects_unknown_pro_tuning_values_before_any_request. + assertThrows { + RunRequest.normalizeChoice("paranoid", "verification_level", VERIFICATION_LEVELS) + } + assertThrows { + RunRequest.normalizeChoice("turbo", "explorer_mode", EXPLORER_MODES) + } + assertThrows { + RunRequest(goal = "x", profile = "pro", verificationLevel = "paranoid") + } + } + + @Test + fun `blank goal and non-uuid task id are rejected`() { + assertThrows { RunRequest(goal = " ", profile = "flash") } + // Mirrors test_submit_rejects_non_uuid_task_id. + val error = assertThrows { + RunRequest(goal = "Open Settings", profile = "flash", taskId = "not-a-uuid") + } + assertTrue(error.message!!.contains("valid UUID")) + } + + @Test + fun `optional fields are omitted when null`() { + val payload = RunRequest(goal = "Open Settings", profile = "pro").toJsonObject() + for (key in listOf( + "device_serial", "expected_output", "enable_outputter", "locked_app_package", + "app_path", "conversation_id", "verification_level", "explorer_mode", "options", + )) { + assertFalse(payload.has(key), "unexpected key: $key") + } + } + + @Test + fun `optional fields are serialized when set`() { + val payload = RunRequest( + goal = "Install the app", + profile = "pro", + expectedOutput = "App installed", + enableOutputter = true, + lockedAppPackage = "com.example.app", + appPath = "/tmp/app.apk", + conversationId = "conv-1", + ).toJsonObject() + assertEquals("App installed", payload.get("expected_output").asString) + assertTrue(payload.get("enable_outputter").asBoolean) + assertEquals("com.example.app", payload.get("locked_app_package").asString) + assertEquals("/tmp/app.apk", payload.get("app_path").asString) + assertEquals("conv-1", payload.get("conversation_id").asString) + } +}