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>
14 KiB
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:
./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 withbounds=[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):
$ 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):
$ 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(default1 MiB) is the live file size that triggers a rotation.ARTEMIS_LOG_KEEP(default5) is how many rotated files survive.runs.jsonlmoves toruns.jsonl.1,.1moves 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:
$ 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:
$ 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:
{"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_buttonkeepscontent-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
Pausedwhile 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
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.