spotify_radio opens a track's own context menu and starts its song radio.
The labels came from a fresh uiautomator dump on the phone, not from a guess:
the entry is "Go to song radio" and it sits below the fold, so the recipe
scrolls before it matches.
Recipe format:
- menu_row "{query}" finds the row like tap_row, then presses the action
button inside that row's own subtree (ui.Row.Action), so the More options
button of any other row is never a candidate. It records the row as {row}.
- a step can name its own failure: wait ... else "reason". The named reason
comes first and the screen's labels still follow (FR-8).
- scroll up|down moves the current list.
- playing with no title waits for the media session to be PLAYING. A radio
changes track every few minutes, so a title match would fail on a working
radio; spotify_play still passes the title it wants.
ui:
- pickBestMatch ignores a match with no usable bounds. An element scrolled out
of its list reports [0,0][0,0], and tapping its "centre" would be a blind tap
in the corner. A wait now only succeeds once the element is on screen.
Run log (FR-10): appendRunLog rotates runs.jsonl when a write would pass
ARTEMIS_LOG_MAX_BYTES (default 1 MiB), keeping ARTEMIS_LOG_KEEP (default 5)
files. History is bounded, never deleted wholesale, and the per-entry cap of
6 screen labels of 60 characters is unchanged.
Live evidence in docs/phase-4-song-radio.md: ok:true in 56.6s, the menu that
opened belonged to the pressed row, the station name "Honky Tonk Women Radio"
on the phone, and the radio playing past its seed track (Layla) per the media
session and dumpsys audio. Both new failure modes were demonstrated failing
with distinct reasons. The mini-bar's transport label stays "Play" while
playing on this build, which is why playback is confirmed from the media
session.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
334 lines
14 KiB
Markdown
334 lines
14 KiB
Markdown
# Phase 4 — song radio, and a bound on the run log
|
|
|
|
Phase 4 of `AGENTS.md` section 11: the `spotify_radio` recipe, plus the run-log
|
|
size bound Sam asked for.
|
|
|
|
Date: 2026-10-16. Phone: `Pixel_8a (akita)` over Tailscale at
|
|
`100.101.49.17:46689` (the port moves on every reboot; nothing hardcodes it).
|
|
|
|
## What was built
|
|
|
|
| File | What it holds |
|
|
|---|---|
|
|
| `recipes/spotify_radio.recipe` | The recipe: search, open the matched row's own menu, choose "Go to song radio", start it, confirm it plays. |
|
|
| `internal/recipe/recipe.go` | New verbs `menu_row` and `scroll`, a step's own failure reason (`else`), and the `{row}` run value. |
|
|
| `internal/recipe/engine.go` | Runs those steps; `playing` now also accepts no title. |
|
|
| `internal/ui/rows.go` | `Row.Action`: the action button **inside** a chosen row. |
|
|
| `internal/ui/actions.go` | A match with no usable bounds is no longer a tap target. |
|
|
| `cmd/artemis/run.go` | The run log rotates instead of growing without limit. |
|
|
|
|
## The recipe
|
|
|
|
One file, plain text, no coordinates anywhere (FR-4):
|
|
|
|
```
|
|
task: spotify_radio
|
|
app: com.spotify.music
|
|
param: query
|
|
row_id: com.spotify.music:id/row_root
|
|
|
|
steps:
|
|
- launch
|
|
- foreground 30s
|
|
- wait "Search" 30s retry 2
|
|
- tap "Search"
|
|
- tap_if "Clear search query"
|
|
- wait "What do you want to listen to?" 30s retry 3
|
|
- tap "What do you want to listen to?"
|
|
- type "{query}"
|
|
- wait "Clear search query" 30s
|
|
- wait_gone "Recents" 30s
|
|
- sleep 1s
|
|
- menu_row "{query}" 40s
|
|
- wait exact "{row}" 20s else "the context menu opened on the wrong row"
|
|
- scroll down
|
|
- wait exact "Go to song radio" 15s retry 3 else "this track offers no Song radio"
|
|
- tap exact "Go to song radio"
|
|
- wait "Radio" 30s else "the song radio page did not open"
|
|
- tap_if "Play playlist"
|
|
- playing 30s retry 3
|
|
```
|
|
|
|
Run it with:
|
|
|
|
```sh
|
|
./bin/artemis run spotify_radio query="Rolling Stones Honky Tonk"
|
|
```
|
|
|
|
### The labels came from the phone, not from a guess
|
|
|
|
A fresh `uiautomator dump` of the track's context menu on the real phone:
|
|
|
|
```
|
|
[TextView] Honky Tonk Women bounds=[201,1044][627,1110] <- the header
|
|
[TextView] The Rolling Stones bounds=[201,1121][525,1175]
|
|
[View] Share bounds=[0,1220][1080,1367]
|
|
[View] Add to Liked Songs bounds=[0,1367][1080,1514]
|
|
[View] Add to playlist bounds=[0,1514][1080,1661]
|
|
[View] Add to Queue bounds=[0,1661][1080,1808]
|
|
[View] Go to Queue bounds=[0,1808][1080,1955]
|
|
[View] Go to album bounds=[0,1955][1080,2102]
|
|
[View] Go to artist bounds=[0,2102][1080,2249]
|
|
[View] Explore Song DNA bounds=[0,2249][1080,2396]
|
|
[View] Go to song radio bounds=[0,0][0,0] <- below the fold
|
|
```
|
|
|
|
Two things this dump settled:
|
|
|
|
* The entry is **`Go to song radio`**, and it is **below the fold**. Spotify
|
|
reports an option it has not drawn yet with `bounds=[0,0][0,0]`, so the recipe
|
|
scrolls first. One swipe brings it in at `[0,1749][1080,1896]`.
|
|
* When the menu is open, the screen dump holds **only the menu**, and the menu's
|
|
header names the track it belongs to. That is what makes the wrong-row check
|
|
possible.
|
|
|
|
### `menu_row` can only press the chosen row's own button
|
|
|
|
`tap_row` picks the row by text; `menu_row` then looks for the row's action
|
|
button **inside that row's own subtree** (`internal/ui/rows.go`, `Row.Action`).
|
|
The More options button of any other row is never a candidate. On the real
|
|
screen the button carries no label of its own (the description sits on a child),
|
|
so each candidate button's subtree is searched and the smallest match wins.
|
|
|
|
### A step can name its own failure (FR-8)
|
|
|
|
```
|
|
- wait exact "Go to song radio" 15s retry 3 else "this track offers no Song radio"
|
|
```
|
|
|
|
The named reason comes first and the technical detail — including the screen's
|
|
visible labels — follows. `retry 3` re-runs the `scroll` above it, so one
|
|
insufficient swipe is not a false failure.
|
|
|
|
### An element that is not on screen is not a tap target
|
|
|
|
`pickBestMatch` now ignores a match with no usable bounds. A node that is
|
|
scrolled out of its list reports `[0,0][0,0]`, and tapping its "centre" would be
|
|
a blind tap in the top-left corner. A wait only succeeds once the element really
|
|
is on screen, which is what lets the no-radio case fail on its own reason
|
|
instead of tapping nothing.
|
|
|
|
## The two failure modes, demonstrated live
|
|
|
|
Each one fails with its own reason, and each one names the screen it gave up on.
|
|
|
|
**The context menu opened on the wrong row** (a real menu for "Honky Tonk Women"
|
|
while the recipe expected a different track):
|
|
|
|
```sh
|
|
$ ARTEMIS_RECIPES=… ./bin/artemis run spotify_radio_wrongrow_check query="Rolling Stones Honky Tonk"
|
|
step 12 menu_row [Rolling Stones Honky Tonk]: ok (2.933s)
|
|
step 13 wait [Honky Tonk Women - Mono Version]: text "Honky Tonk Women - Mono Version" did not appear within 20s
|
|
FAILED …: the context menu opened on the wrong row: text "Honky Tonk Women - Mono Version"
|
|
did not appear within 20s; foreground=com.spotify.music;
|
|
screen shows: "Honky Tonk Women", "The Rolling Stones", "Share", "Add to Liked Songs",
|
|
"Add to playlist", "Add to Queue"
|
|
```
|
|
|
|
**The item offers no song radio** (a real Spotify menu with no such entry):
|
|
|
|
```sh
|
|
$ ARTEMIS_RECIPES=… ./bin/artemis run spotify_radio_noradio_check query="The Daily Aus"
|
|
step 17 wait [Go to song radio]: text "Go to song radio" did not appear within 15s
|
|
FAILED …: this track offers no Song radio: text "Go to song radio" did not appear within 15s;
|
|
foreground=com.spotify.music; screen shows: "The Daily Aus Trailer",
|
|
"The Daily Aus • By The Daily Aus", "Share", "Add to Your Episodes", "Add to playlist", "Download"
|
|
```
|
|
|
|
Both reasons are distinct, and neither run tapped anything it had not matched.
|
|
|
|
A third, incidental failure also came up and is handled cleanly: **the matched
|
|
row has no More options button at all**. On this Spotify build only *song* rows
|
|
in search results carry one; a playlist row does not.
|
|
|
|
```
|
|
step 12 (menu_row {query}) failed: row "Honky Tonk Woman (The Rolling Stones) Covers"
|
|
has no "More options" button; foreground=com.spotify.music; screen shows: …
|
|
```
|
|
|
|
### Note on the no-radio demonstration
|
|
|
|
The shipped recipe reaches that branch for a track whose menu has no "Go to song
|
|
radio" entry. On this Spotify build every **song** row in search results offers
|
|
one, so the branch was demonstrated on a real Spotify context menu that
|
|
genuinely lacks the entry: a **podcast episode's** (`Share`, `Add to Your
|
|
Episodes`, `Download`, `Mark as finished`, `Go to episode` — no radio). The
|
|
demonstration recipe navigates search → show → episode menu; the guard, the
|
|
wording and the code path are the recipe's own.
|
|
|
|
## Work item 2 — bound `logs/runs.jsonl`
|
|
|
|
`appendRunLog` rotates when a write would pass the bound:
|
|
|
|
* `ARTEMIS_LOG_MAX_BYTES` (default `1 MiB`) is the live file size that triggers
|
|
a rotation.
|
|
* `ARTEMIS_LOG_KEEP` (default `5`) is how many rotated files survive.
|
|
* `runs.jsonl` moves to `runs.jsonl.1`, `.1` moves to `.2`, and the oldest file
|
|
is deleted: history is bounded, never deleted wholesale, so the FR-10 audit
|
|
trail that caught the lock refusal and the wrong-element tap is kept.
|
|
* A single entry larger than the bound is still written — the bound must never
|
|
drop an audit record.
|
|
|
|
The per-entry cap is unchanged: a failing step still reports at most 6 screen
|
|
labels of at most 60 characters each (`internal/recipe/engine.go`,
|
|
`describeScreen`).
|
|
|
|
### Demonstration
|
|
|
|
Five real runs against a bound of 300 bytes, keeping 2 rotated files:
|
|
|
|
```sh
|
|
$ for i in 1 2 3 4 5; do
|
|
ARTEMIS_RECIPES=…/recipes ARTEMIS_LOG=…/runs.jsonl \
|
|
ARTEMIS_LOG_MAX_BYTES=300 ARTEMIS_LOG_KEEP=2 ./bin/artemis run _demo
|
|
done
|
|
$ ls -l runs.jsonl*
|
|
-rw-r--r-- 1 sam users 160 runs.jsonl # the fifth run
|
|
-rw-r--r-- 1 sam users 160 runs.jsonl.1 # the fourth
|
|
-rw-r--r-- 1 sam users 160 runs.jsonl.2 # the third
|
|
$ ls runs.jsonl.3
|
|
ls: cannot access 'runs.jsonl.3': No such file or directory
|
|
```
|
|
|
|
Every file is exactly 160 bytes, so each holds one complete JSON line, and every
|
|
line parses. The first two runs fell off the end, because `keep=2` bounds the
|
|
history. Unit tests cover the same path:
|
|
`TestRotateRunLogKeepsRecentFiles` and
|
|
`TestAppendRunLogRotatesAndKeepsEveryEntry` (`cmd/artemis/run_test.go`).
|
|
|
|
## Live run log
|
|
|
|
The recipe ran end to end on the real phone, 19 steps, 56.6 seconds:
|
|
|
|
```sh
|
|
$ nix shell nixpkgs#go --command ./bin/artemis run spotify_radio query="Rolling Stones Honky Tonk"
|
|
step 12 menu_row [Rolling Stones Honky Tonk]: ok (2.957s)
|
|
step 13 wait [Honky Tonk Women]: ok (2.538s)
|
|
step 14 scroll [down]: ok (3.437s)
|
|
step 15 wait [Go to song radio]: ok (2.713s)
|
|
step 16 tap [Go to song radio]: ok (5.686s)
|
|
step 17 wait [Radio]: ok (3.017s)
|
|
step 18 tap_if [Play playlist]: ok (3.004s)
|
|
step 19 playing []: ok (1.793s)
|
|
OK spotify_radio in 56.632s
|
|
```
|
|
|
|
The entry in `logs/runs.jsonl`:
|
|
|
|
```json
|
|
{"time":"2026-10-16T15:15:04+11:00","task":"spotify_radio",
|
|
"params":{"query":"Rolling Stones Honky Tonk","row":"Honky Tonk Women"},
|
|
"ok":true,"duration_ms":56632, ...}
|
|
```
|
|
|
|
The steps that matter, with the detail the engine recorded:
|
|
|
|
| Step | Detail |
|
|
|---|---|
|
|
| 12 `menu_row` | `opened the "More options" menu of row "Honky Tonk Women" at (891,878)` |
|
|
| 13 `wait {row}` | `found android.widget.TextView[-] text="Honky Tonk Women" bounds=[201,1044][627,1110]` — the menu belongs to the pressed row |
|
|
| 15 `wait "Go to song radio"` | `found android.view.View[-] text="Go to song radio" bounds=[0,1972][1080,2119]` — on screen after the scroll |
|
|
| 17 `wait "Radio"` | found on the radio page, e.g. `"Download Playlist: Honky Tonk Women Radio"` |
|
|
| 19 `playing` | `com.spotify.music is playing "Layla, Derek & The Dominos, Layla And Other Assorted Love Songs (Remastered 2010)"` |
|
|
|
|
## What the phone showed
|
|
|
|
**The station name, on the radio page.** The now-playing surface for the radio
|
|
is its page, which names the station:
|
|
|
|
```
|
|
[TextView] Honky Tonk Women Radio bounds=[42,700][1038,960]
|
|
[TextView] With The Rolling Stones, Creedence Clearwater Revival, Lynyrd Skynyrd and more
|
|
[TextView] Made for you
|
|
[TextView] 1,218 saves • 3h 41min
|
|
[View] Play playlist bounds=[875,1362][1054,1541]
|
|
```
|
|
|
|
**The radio moved off the seed track.** It started on "Honky Tonk Women" and was
|
|
playing **Layla — Derek & The Dominos** by the time the run finished. A single
|
|
track cannot do that; a radio can.
|
|
|
|
**Independent reads, not Artemis's own claim:**
|
|
|
|
```
|
|
$ adb shell dumpsys media_session | grep -A12 'package=com.spotify.music'
|
|
state=PlaybackState {state=PLAYING(3), position=56, buffered position=0, speed=1.0, ...}
|
|
metadata: size=18, description=Layla, Derek & The Dominos, Layla And Other Assorted Love Songs (Remastered 2010)
|
|
|
|
$ adb shell dumpsys audio | grep '10340'
|
|
AudioPlaybackConfiguration piid:22679 deviceIds:[3] type:AAudio u/pid:10340/14004
|
|
state:started attr:AudioAttributes: usage=USAGE_MEDIA ... sampleRate=44100
|
|
```
|
|
|
|
uid 10340 is Spotify, `state:started` means its audio track is rendering, and
|
|
the session position reset to `56` with a new `updated` timestamp when the radio
|
|
moved to the next track.
|
|
|
|
### The transport label, and one DoD wording
|
|
|
|
The Phase 4 brief asks for evidence of "the transport shows `Pause`" and "the
|
|
now-playing bar names a radio/station, not the single track". On this Spotify
|
|
build neither is how the app behaves, and this was measured, not assumed:
|
|
|
|
* The mini-bar's `play_pause_button` keeps `content-desc="Play"` **while the
|
|
media session says PLAYING**. Phase 3 found the same
|
|
(`docs/phase-3-spotify-recipe.md`), and it is why both recipes confirm
|
|
playback through the media session instead of an on-screen label.
|
|
* The mini-bar names the **current track** (`track_info_view_title` = "Layla",
|
|
subtitle "Derek & The Dominos"). The **station** name is on the radio page
|
|
("Honky Tonk Women Radio"), which is the now-playing surface for a radio.
|
|
* The only "Paused" text on screen is the currently-playing row's state marker
|
|
in the radio track list, and it reads `Paused` while the track is playing.
|
|
|
|
So the evidence delivered is the intent behind the wording: a real radio was
|
|
opened and is playing — the station is named on the phone, the radio's queue has
|
|
moved past the seed track, and Android's media session and audio subsystem both
|
|
say Spotify is playing.
|
|
|
|
## The phone's condition during this phase
|
|
|
|
Spotify on this Pixel 8a wedges under memory pressure (Phase 3 attempts 9, 10 and
|
|
13). It happened again here, harder: the activity stayed `RESUMED` while its
|
|
window reported `isOnScreen=false`, it drew nothing and consumed no taps.
|
|
`MemFree` was ~320 MB with 3.6 GB of its 3.9 GB swap in use. Taps, a back key, a
|
|
`CLOSE_SYSTEM_DIALOGS` broadcast, a display-rotation change and a re-launch all
|
|
left it unchanged.
|
|
|
|
The documented remedy is a force-stop, which is a restart on Sam's phone, so it
|
|
was **asked for and approved** (issue card `aa836045-…`, answered "Yes —
|
|
force-stop and relaunch Spotify") and used to run each live step above.
|
|
|
|
This is why every recipe step is quick and why `playing` accepts no title: a
|
|
radio legitimately changes track, and the phone is slow.
|
|
|
|
## Verification status
|
|
|
|
| Item | State |
|
|
|---|---|
|
|
| `spotify_radio` starts a real radio | **live, verified** — 19 steps, 56.6s, radio playing past the seed track |
|
|
| The matched row's own More options is opened | **live, verified** — `opened the "More options" menu of row "Honky Tonk Women"` |
|
|
| Context-menu labels came from a real dump | **live, verified** — dump recorded above |
|
|
| Wrong-row failure has its own reason | **live, demonstrated** |
|
|
| No-radio failure has its own reason | **live, demonstrated** (a real menu with no song radio) |
|
|
| A row with no More options fails cleanly | **live, observed** (playlist row in search results) |
|
|
| Run log rotates at the bound | unit tested + **demonstrated live** |
|
|
| The per-entry screen-label cap is unchanged | unchanged |
|
|
| A match with no usable bounds is never tapped | unit tested (`internal/ui`) + used live by the radio scroll step |
|
|
| `foreground` still guards against acting on another app | unchanged, used by both recipes |
|
|
|
|
## Reproducing it
|
|
|
|
```sh
|
|
cd ~/paseo/projects/artemis_control
|
|
nix shell nixpkgs#go -c go test ./...
|
|
./bin/artemis run spotify_radio query="Rolling Stones Honky Tonk"
|
|
tail -1 logs/runs.jsonl
|
|
adb shell dumpsys media_session | grep -A12 'package=com.spotify.music'
|
|
adb shell dumpsys audio | grep 10340
|
|
```
|
|
|
|
The phone must be awake and unlocked. On a quiet phone the run takes about 55
|
|
seconds; `total_timeout: 420s` bounds a loaded one.
|
|
|
|
Phase 5 (maps) has not been started, on instruction.
|