Split the newest reply once its turn ends, and make the UI harness reusable
The transcript's remaining lag was the newest assistant reply: transcriptUnits kept the last row whole -- right while it streams (splitting a changing text is a parse per delta), wrong forever after, so a session that ends on a long reply drew it as one lazy-list item with every node alive. On a Pixel 9 Pro XL that was 13.8ms of draw phase a frame, 79% of it the framework's own per-node bookkeeping, against a 34,996px item. An AssistantMsg now carries `settled`, folded from the status event that ends its turn (status changes are transcript events with seqs, so replay settles the same way), and cleared if a delta ever grows the message again. A settled newest reply splits like every other. Folding it -- rather than reading the screen's status -- routes the resplit through the held-events gate, so it can only happen at the newest end while pinned, never under a reader. The "session is working" predicate now lives once, in sessionWorking(). Measured on the emulator, same session and gestures, a 43KB reply as the last row: draw phase 3.92ms -> 1.20ms per frame, framework share 3.07ms (78%) -> 0.54ms (45%), worst single measure 82.5ms -> 9.1ms. The report's "on screen" line went from one 60,674px AssistantMsg to five blocks of 95-846px. A live streamed turn settles and splits the moment it goes idle. The harness half, asked for by Bryan: ui-sandbox.sh now derives its port and root from the checkout name (two checkouts' sandboxes cannot reach each other), keeps its token in ~/.config/ai-app/sandbox-token and salvages enrolled device tokens across restarts (enrol the emulator once, ever), and gained the driving verbs every UI session was re-inventing in /tmp: spawn, send (text or @file), api. transcript-bench.sh is the standard scroll-and-report measurement. AGENTS.md documents all of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
917eb9a7b3
commit
7a48f8ff1f
8 files changed
+250
-23
No files matched your search
@@ -369,7 +369,7 @@ first if a remote spawn ever mangles an argument.
|
|||||||
conversation somebody may still be in. **A transcript never goes in this
|
conversation somebody may still be in. **A transcript never goes in this
|
||||||
repository**: they hold whatever was said, read and written in that
|
repository**: they hold whatever was said, read and written in that
|
||||||
session, and `~/repos` is shared with the host besides.
|
session, and `~/repos` is shared with the host besides.
|
||||||
- **`app/ui-sandbox.sh` is the rig for anything that lists or deletes
|
- **`app/ui-sandbox.sh` is the rig for driving the UI against invented
|
||||||
sessions.** It starts a second `ai-server` with its own `$HOME`, config
|
sessions.** It starts a second `ai-server` with its own `$HOME`, config
|
||||||
and data directory, holding eight invented Claude Code transcripts and a
|
and data directory, holding eight invented Claude Code transcripts and a
|
||||||
`claude` that is two lines of shell. That isolation is the point: the
|
`claude` that is two lines of shell. That isolation is the point: the
|
||||||
@@ -378,13 +378,31 @@ first if a remote spawn ever mangles an argument.
|
|||||||
server deletes somebody's conversation and exercising *import* starts a
|
server deletes somebody's conversation and exercising *import* starts a
|
||||||
real `--resume` on the owner's account. Neither is a price worth paying to
|
real `--resume` on the owner's account. Neither is a price worth paying to
|
||||||
look at a list. It shares the real TLS certificates, because the
|
look at a list. It shares the real TLS certificates, because the
|
||||||
installed APK pins that CA, so run it while the ordinary server is down.
|
installed APK pins that CA.
|
||||||
|
Its port and root are derived from the checkout's name, so two checkouts'
|
||||||
|
sandboxes (and the emulators enrolled against them) cannot reach each
|
||||||
|
other, and its token is generated once into
|
||||||
|
`~/.config/ai-app/sandbox-token` and carried across restarts along with
|
||||||
|
any tokens the server's own enrolment flow appended -- so the emulator app
|
||||||
|
is enrolled **once** (the start banner prints the command) and stays
|
||||||
|
enrolled. It also carries the driving verbs every UI investigation needs,
|
||||||
|
so none of this is re-derived per session:
|
||||||
|
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||||
|
`./ui-sandbox.sh send SID text|@file`, and
|
||||||
|
`./ui-sandbox.sh api /path [curl args]` for everything else.
|
||||||
It passes `--delay` by default for the reason the next entry gives, and
|
It passes `--delay` by default for the reason the next entry gives, and
|
||||||
`AI_SANDBOX_BIG_MB` puts one large transcript among the small ones --
|
`AI_SANDBOX_BIG_MB` puts one large transcript among the small ones --
|
||||||
`AI_SANDBOX_SPAWN_DELAY` makes the fake CLI slow to start. Both exist
|
`AI_SANDBOX_SPAWN_DELAY` makes the fake CLI slow to start. Both exist
|
||||||
because operations that finish in milliseconds have states on the way that
|
because operations that finish in milliseconds have states on the way that
|
||||||
nothing can observe, and an unobservable state is one where broken and
|
nothing can observe, and an unobservable state is one where broken and
|
||||||
working look identical.
|
working look identical.
|
||||||
|
- **`app/transcript-bench.sh` is the standard scroll measurement.** It
|
||||||
|
opens the first session (or `-k` keeps the current screen), scrolls a
|
||||||
|
fixed gesture loop, and prints the app's render report -- the same one
|
||||||
|
the in-app copy button produces, whose `on screen:` line names what the
|
||||||
|
viewport was actually holding. Compare two runs of it with the same
|
||||||
|
gestures; the emulator's absolute frame times transfer nothing, the
|
||||||
|
report's accounting does.
|
||||||
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
||||||
phone's requests take tens to hundreds of milliseconds, and several
|
phone's requests take tens to hundreds of milliseconds, and several
|
||||||
faults live entirely in what the app does *while* one is outstanding. On
|
faults live entirely in what the app does *while* one is outstanding. On
|
||||||
|
|||||||
@@ -297,6 +297,16 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
|
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
|
||||||
* since.
|
* since.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
|
||||||
|
*
|
||||||
|
* One predicate because two readers have to agree on the list: the session screen's working
|
||||||
|
* indicator, and the fold's decision that the newest reply is finished
|
||||||
|
* ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a
|
||||||
|
* state, and the drift would be a reply that never splits or one split mid-stream.
|
||||||
|
*/
|
||||||
|
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
|
||||||
|
|
||||||
fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
||||||
when (event) {
|
when (event) {
|
||||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
|
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ fun StatusText(status: String) {
|
|||||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (status == "running" || status == "compacting") {
|
if (sessionWorking(status)) {
|
||||||
// The same colour as the word beside it: the two are one signal, and a spinner in
|
// The same colour as the word beside it: the two are one signal, and a spinner in
|
||||||
// the theme's accent says the state is something other than what the label says.
|
// the theme's accent says the state is something other than what the label says.
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
// them. From the server rather than from this screen, so a rename sent from the settings
|
// them. From the server rather than from this screen, so a rename sent from the settings
|
||||||
// screen -- or from another device -- is drawn waiting here too.
|
// screen -- or from another device -- is drawn waiting here too.
|
||||||
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
||||||
val running = status == "running" || status == "compacting"
|
val running = sessionWorking(status)
|
||||||
var moreHistory by remember { mutableStateOf(true) }
|
var moreHistory by remember { mutableStateOf(true) }
|
||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -45,7 +45,27 @@ sealed class TranscriptItem {
|
|||||||
val images: List<String> = emptyList(),
|
val images: List<String> = emptyList(),
|
||||||
) : TranscriptItem()
|
) : TranscriptItem()
|
||||||
|
|
||||||
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
|
data class AssistantMsg(
|
||||||
|
override val seq: Long,
|
||||||
|
val text: String,
|
||||||
|
/**
|
||||||
|
* Whether this reply is finished: the session has stopped working since its last delta.
|
||||||
|
*
|
||||||
|
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
|
||||||
|
* streaming reply's text changes per delta and splitting a changing text is a parse per
|
||||||
|
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
|
||||||
|
* drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9
|
||||||
|
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the
|
||||||
|
* framework's own bookkeeping, which grows with alive nodes.
|
||||||
|
*
|
||||||
|
* Folded from the status event that ended the turn, rather than read off the screen's
|
||||||
|
* status, because rows only change through the held-events gate: the split changes the
|
||||||
|
* newest row's list identity, and doing that from a status flip while somebody is reading
|
||||||
|
* inside that reply would step the list under them. An event has to wait for the reader to
|
||||||
|
* be at the newest end; a screen state does not.
|
||||||
|
*/
|
||||||
|
val settled: Boolean = false,
|
||||||
|
) : TranscriptItem()
|
||||||
|
|
||||||
data class ToolRun(
|
data class ToolRun(
|
||||||
override val seq: Long,
|
override val seq: Long,
|
||||||
@@ -362,7 +382,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
// every frame, and the list would jump for the whole of a streamed answer.
|
// every frame, and the list would jump for the whole of a streamed answer.
|
||||||
val last = items.lastOrNull()
|
val last = items.lastOrNull()
|
||||||
if (last is TranscriptItem.AssistantMsg) {
|
if (last is TranscriptItem.AssistantMsg) {
|
||||||
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
// A message growing again is not finished, whatever a status said in between.
|
||||||
|
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
|
||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
||||||
}
|
}
|
||||||
@@ -456,7 +477,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
// is nothing it belongs above.
|
// is nothing it belongs above.
|
||||||
is SessionEvent.MessageDropped -> items
|
is SessionEvent.MessageDropped -> items
|
||||||
is SessionEvent.Settings -> items
|
is SessionEvent.Settings -> items
|
||||||
is SessionEvent.Status -> items
|
is SessionEvent.Status -> settleReply(items, event.state)
|
||||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||||
is SessionEvent.Image ->
|
is SessionEvent.Image ->
|
||||||
// Under the call that produced it when there is one, and a row of
|
// Under the call that produced it when there is one, and a row of
|
||||||
@@ -479,6 +500,20 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
is SessionEvent.UsageDelta -> items
|
is SessionEvent.UsageDelta -> items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A status saying the session stopped working is the moment its newest reply is finished.
|
||||||
|
*
|
||||||
|
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the
|
||||||
|
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles
|
||||||
|
* its replies the same way a live one does.
|
||||||
|
*/
|
||||||
|
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
||||||
|
if (sessionWorking(state)) return items
|
||||||
|
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
|
||||||
|
if (last.settled) return items
|
||||||
|
return items.dropLast(1) + last.copy(settled = true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateTool(
|
private fun updateTool(
|
||||||
items: List<TranscriptItem>,
|
items: List<TranscriptItem>,
|
||||||
id: String,
|
id: String,
|
||||||
|
|||||||
@@ -126,10 +126,12 @@ sealed class TranscriptUnit {
|
|||||||
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
|
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
|
||||||
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
|
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
|
||||||
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
|
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
|
||||||
* splitting. The reply still arriving -- the last row -- stays whole: its text changes with every
|
* splitting. The reply still arriving -- the newest row, until the status event that ends its turn
|
||||||
* delta, and splitting it here would parse the whole message per delta on whichever thread is
|
* marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta,
|
||||||
* composing. [AssistantMessage]'s own streaming path already parses deltas off the main thread and
|
* and splitting it here would parse the whole message per delta on whichever thread is composing.
|
||||||
* gives the live message a layer per block.
|
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
|
||||||
|
* live message a layer per block. Once settled it splits like every other reply, which is what
|
||||||
|
* bounds the newest row's cost after a session ends on a long one.
|
||||||
*
|
*
|
||||||
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
|
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
|
||||||
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
|
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
|
||||||
@@ -163,7 +165,9 @@ fun transcriptUnits(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item is TranscriptItem.AssistantMsg && index != rows.lastIndex) {
|
} else if (
|
||||||
|
item is TranscriptItem.AssistantMsg && (item.settled || index != rows.lastIndex)
|
||||||
|
) {
|
||||||
var ordinal = 0
|
var ordinal = 0
|
||||||
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
|
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
|
||||||
replies.partsOf(item.text).forEach { part ->
|
replies.partsOf(item.text).forEach { part ->
|
||||||
|
|||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Scrolls the transcript and prints the app's own render report -- the
|
||||||
|
# standard measurement for "is the transcript smooth", so a perf session
|
||||||
|
# starts from a number instead of re-inventing this loop in /tmp.
|
||||||
|
#
|
||||||
|
# ./transcript-bench.sh restart the app, open the first
|
||||||
|
# session, scroll, report
|
||||||
|
# ./transcript-bench.sh -k keep whatever screen is open now
|
||||||
|
# ./transcript-bench.sh -n 4 swipe cycles (default 6; one cycle is
|
||||||
|
# two swipes back and two forward)
|
||||||
|
#
|
||||||
|
# What it prints is the report the in-app copy button produces, plus any
|
||||||
|
# duplicate-key warnings the flatten logged. Absolute frame times from the
|
||||||
|
# emulator are worthless (it renders in software); what transfers is the
|
||||||
|
# report's own accounting -- what was on screen, and where the draw phase
|
||||||
|
# went. Compare two runs of this with the same gestures, not one run
|
||||||
|
# against a phone.
|
||||||
|
#
|
||||||
|
# Coordinates are the ai-app AVD's 1080x2400 screen: the report button in
|
||||||
|
# the session top bar, and the first row of the session list.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
. ./android-env.sh >/dev/null 2>&1
|
||||||
|
|
||||||
|
keep=""
|
||||||
|
cycles=6
|
||||||
|
while getopts kn: flag; do
|
||||||
|
case $flag in
|
||||||
|
k) keep=1 ;;
|
||||||
|
n) cycles=$OPTARG ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$keep" ]; then
|
||||||
|
adb shell am force-stop com.example.aiapp
|
||||||
|
adb shell am start -n com.example.aiapp/.MainActivity >/dev/null
|
||||||
|
sleep 5
|
||||||
|
ui-trace record -d 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# First tap resets the report's "work since last copied" window, so the
|
||||||
|
# numbers cover exactly the swipes between the two taps.
|
||||||
|
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
|
||||||
|
adb logcat -c
|
||||||
|
|
||||||
|
DO=""
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt "$cycles" ]; do
|
||||||
|
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
eval ui-trace record -d $((cycles * 4400 + 2000)) $DO -o /tmp/bench-scroll.txt >/dev/null 2>&1
|
||||||
|
|
||||||
|
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-report.txt >/dev/null 2>&1
|
||||||
|
sleep 1
|
||||||
|
adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //'
|
||||||
|
adb logcat -d 2>/dev/null | grep -i "duplicate unit key" | head -3 || true
|
||||||
+109
-11
@@ -30,19 +30,56 @@
|
|||||||
# nobody can see.
|
# nobody can see.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox}
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
TOKEN=${AI_SANDBOX_TOKEN:-sandbox}
|
SERVER_DIR=$SCRIPT_DIR/../server
|
||||||
PORT=${AI_SANDBOX_PORT:-8443}
|
# The checkout's name, because several checkouts of this repo run sessions
|
||||||
|
# at once and each has its own emulator: the root and the port both carry
|
||||||
|
# it, so one checkout's sandbox (and the phone enrolled against it) can
|
||||||
|
# never reach another's. Same rule as the per-checkout AVDs.
|
||||||
|
CHECKOUT=$(basename "$(dirname "$SCRIPT_DIR")")
|
||||||
|
|
||||||
|
ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox-$CHECKOUT}
|
||||||
|
# Derived, not chosen: stable for this checkout across sessions (so an
|
||||||
|
# enrolled emulator app keeps working), different between checkouts, and
|
||||||
|
# away from 8443 where real dev servers get started.
|
||||||
|
if [ -n "${AI_SANDBOX_PORT:-}" ]; then
|
||||||
|
PORT=$AI_SANDBOX_PORT
|
||||||
|
elif [ -f "$ROOT/port" ]; then
|
||||||
|
# Whatever the running (or last) server was actually started on, so the
|
||||||
|
# driving verbs below reach it even when it was started with an override.
|
||||||
|
PORT=$(cat "$ROOT/port")
|
||||||
|
else
|
||||||
|
PORT=$((8500 + $(printf %s "$CHECKOUT" | cksum | cut -d' ' -f1) % 80))
|
||||||
|
fi
|
||||||
DELAY=${AI_SANDBOX_DELAY:-1200}
|
DELAY=${AI_SANDBOX_DELAY:-1200}
|
||||||
SPAWN_DELAY=${AI_SANDBOX_SPAWN_DELAY:-0}
|
SPAWN_DELAY=${AI_SANDBOX_SPAWN_DELAY:-0}
|
||||||
BIG_MB=${AI_SANDBOX_BIG_MB:-40}
|
BIG_MB=${AI_SANDBOX_BIG_MB:-40}
|
||||||
CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}
|
CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
# Generated once and kept outside the repo (it is a credential, however
|
||||||
SERVER_DIR=$SCRIPT_DIR/../server
|
# small the stakes), so the emulator app enrolled against the sandbox stays
|
||||||
|
# enrolled across restarts and VM reboots instead of every session
|
||||||
|
# re-deriving why the server says "invalid bearer token". URL-safe
|
||||||
|
# characters only, so the enrolment deep link needs no encoding.
|
||||||
|
TOKEN_FILE=${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/sandbox-token
|
||||||
|
if [ -z "${AI_SANDBOX_TOKEN:-}" ] && [ ! -f "$TOKEN_FILE" ]; then
|
||||||
|
mkdir -p "$(dirname "$TOKEN_FILE")"
|
||||||
|
(umask 077 && head -c 24 /dev/urandom | base64 | tr '+/' '-_' >"$TOKEN_FILE")
|
||||||
|
fi
|
||||||
|
TOKEN=${AI_SANDBOX_TOKEN:-$(cat "$TOKEN_FILE")}
|
||||||
|
hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
|
||||||
|
|
||||||
PIDFILE=$ROOT/server.pid
|
PIDFILE=$ROOT/server.pid
|
||||||
LOG=$ROOT/server.log
|
LOG=$ROOT/server.log
|
||||||
|
|
||||||
|
# An authenticated request to the running sandbox, so nothing driving it
|
||||||
|
# has to re-derive the port and token: `./ui-sandbox.sh api /sessions`.
|
||||||
|
api() {
|
||||||
|
api_path=$1
|
||||||
|
shift
|
||||||
|
curl -sk "https://127.0.0.1:$PORT$api_path" -H "Authorization: Bearer $TOKEN" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
# By pid rather than by pattern: a `pkill -f` for something as generic as
|
# By pid rather than by pattern: a `pkill -f` for something as generic as
|
||||||
# "ai-server" also matches the shell running this script, which kills the
|
# "ai-server" also matches the shell running this script, which kills the
|
||||||
# script mid-flight and leaves the restart never having happened.
|
# script mid-flight and leaves the restart never having happened.
|
||||||
@@ -56,12 +93,65 @@ stop_server() {
|
|||||||
rm -f "$PIDFILE"
|
rm -f "$PIDFILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "${1:-start}" = stop ]; then
|
case "${1:-start}" in
|
||||||
|
stop)
|
||||||
stop_server
|
stop_server
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
;;
|
||||||
|
# The driving verbs live here rather than in each session's /tmp scripts,
|
||||||
|
# because every UI investigation needs the same three: a session to point
|
||||||
|
# the phone at, a message in it (often a large one, hence @file), and an
|
||||||
|
# arbitrary authenticated request for everything else.
|
||||||
|
api) # ./ui-sandbox.sh api /path [curl args...]
|
||||||
|
shift
|
||||||
|
api "$@"
|
||||||
|
echo
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id
|
||||||
|
api /sessions -X POST -H 'content-type: application/json' \
|
||||||
|
-d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
|
||||||
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
send) # ./ui-sandbox.sh send SID text... (or: send SID @file)
|
||||||
|
sid=$2
|
||||||
|
shift 2
|
||||||
|
python3 -c 'import json, sys
|
||||||
|
arg = sys.argv[1]
|
||||||
|
text = open(arg[1:]).read() if arg.startswith("@") else " ".join(sys.argv[1:])
|
||||||
|
print(json.dumps({"text": text}))' "$@" >"$ROOT/send.json"
|
||||||
|
api "/sessions/$sid/message" -X POST -H 'content-type: application/json' \
|
||||||
|
--data-binary "@$ROOT/send.json"
|
||||||
|
echo
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
start) ;;
|
||||||
|
*)
|
||||||
|
echo "ui-sandbox.sh: unknown command '$1' (start, stop, api, spawn, send)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
stop_server
|
stop_server
|
||||||
|
# Tokens the server's own enrolment flow appended to the old config are the
|
||||||
|
# phones enrolled against this sandbox; a restart regenerates the fixtures
|
||||||
|
# but must not orphan those, or the app greets the next session with
|
||||||
|
# "server rejected this device's token" and an afternoon of why.
|
||||||
|
salvaged=""
|
||||||
|
if [ -f "$ROOT/config.ron" ]; then
|
||||||
|
salvaged=$(awk '
|
||||||
|
/^tokens: \[/ { in_tokens = 1; next }
|
||||||
|
in_tokens && /^\],/ { exit }
|
||||||
|
in_tokens {
|
||||||
|
entry = entry $0 "\n"
|
||||||
|
if ($0 ~ /\),/) {
|
||||||
|
if (entry !~ h) entries = entries entry
|
||||||
|
entry = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { printf "%s", entries }' h="$hash" "$ROOT/config.ron")
|
||||||
|
fi
|
||||||
rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron"
|
rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron"
|
||||||
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
|
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
|
||||||
mkdir -p "$PROJECTS" "$ROOT/sessions"
|
mkdir -p "$PROJECTS" "$ROOT/sessions"
|
||||||
@@ -130,14 +220,13 @@ awk -v mb="$BIG_MB" 'BEGIN {
|
|||||||
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
||||||
}' > "$big"
|
}' > "$big"
|
||||||
|
|
||||||
hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
|
|
||||||
cat >"$ROOT/config.ron" <<RON
|
cat >"$ROOT/config.ron" <<RON
|
||||||
tokens: [
|
tokens: [
|
||||||
(
|
(
|
||||||
name: "sandbox",
|
name: "sandbox",
|
||||||
sha256: "$hash",
|
sha256: "$hash",
|
||||||
),
|
),
|
||||||
],
|
$salvaged],
|
||||||
setups: [
|
setups: [
|
||||||
(
|
(
|
||||||
id: "local",
|
id: "local",
|
||||||
@@ -179,6 +268,7 @@ HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
|
|||||||
pid=$!
|
pid=$!
|
||||||
disown -h "$pid" 2>/dev/null || true
|
disown -h "$pid" 2>/dev/null || true
|
||||||
echo "$pid" >"$PIDFILE"
|
echo "$pid" >"$PIDFILE"
|
||||||
|
echo "$PORT" >"$ROOT/port"
|
||||||
|
|
||||||
# Waited for rather than assumed: the enrolment below fails silently against
|
# Waited for rather than assumed: the enrolment below fails silently against
|
||||||
# a server that has not bound yet, and the app then shows a network error
|
# a server that has not bound yet, and the app then shows a network error
|
||||||
@@ -191,12 +281,20 @@ while [ "$tries" -lt 50 ]; do
|
|||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Percent-encoded because the app URL-decodes the deep link's query: a
|
||||||
|
# token with '+' in it enrols as one with a space, and nothing reports it.
|
||||||
|
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
||||||
cat <<INFO
|
cat <<INFO
|
||||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||||
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||||
|
|
||||||
enrol the emulator:
|
enrol the emulator (once; it survives sandbox restarts):
|
||||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'"
|
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
|
||||||
|
|
||||||
|
drive it:
|
||||||
|
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||||
|
./ui-sandbox.sh send SID text|@file a message into it
|
||||||
|
./ui-sandbox.sh api /sessions/SID any authenticated request
|
||||||
|
|
||||||
stop it:
|
stop it:
|
||||||
./ui-sandbox.sh stop
|
./ui-sandbox.sh stop
|
||||||
|
|||||||
Reference in new issue
Block a user