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
+109
-11
@@ -30,19 +30,56 @@
|
||||
# nobody can see.
|
||||
set -eu
|
||||
|
||||
ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox}
|
||||
TOKEN=${AI_SANDBOX_TOKEN:-sandbox}
|
||||
PORT=${AI_SANDBOX_PORT:-8443}
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR=$SCRIPT_DIR/../server
|
||||
# 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}
|
||||
SPAWN_DELAY=${AI_SANDBOX_SPAWN_DELAY:-0}
|
||||
BIG_MB=${AI_SANDBOX_BIG_MB:-40}
|
||||
CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR=$SCRIPT_DIR/../server
|
||||
# Generated once and kept outside the repo (it is a credential, however
|
||||
# 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
|
||||
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
|
||||
# "ai-server" also matches the shell running this script, which kills the
|
||||
# script mid-flight and leaves the restart never having happened.
|
||||
@@ -56,12 +93,65 @@ stop_server() {
|
||||
rm -f "$PIDFILE"
|
||||
}
|
||||
|
||||
if [ "${1:-start}" = stop ]; then
|
||||
case "${1:-start}" in
|
||||
stop)
|
||||
stop_server
|
||||
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
|
||||
# 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"
|
||||
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
|
||||
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}}}"
|
||||
}' > "$big"
|
||||
|
||||
hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
|
||||
cat >"$ROOT/config.ron" <<RON
|
||||
tokens: [
|
||||
(
|
||||
name: "sandbox",
|
||||
sha256: "$hash",
|
||||
),
|
||||
],
|
||||
$salvaged],
|
||||
setups: [
|
||||
(
|
||||
id: "local",
|
||||
@@ -179,6 +268,7 @@ HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
|
||||
pid=$!
|
||||
disown -h "$pid" 2>/dev/null || true
|
||||
echo "$pid" >"$PIDFILE"
|
||||
echo "$PORT" >"$ROOT/port"
|
||||
|
||||
# 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
|
||||
@@ -191,12 +281,20 @@ while [ "$tries" -lt 50 ]; do
|
||||
sleep 0.2
|
||||
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
|
||||
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)
|
||||
|
||||
enrol the emulator:
|
||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'"
|
||||
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=$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:
|
||||
./ui-sandbox.sh stop
|
||||
|
||||
Reference in new issue
Block a user