Files
ai-app/app/ui-sandbox.sh
T
irisandClaude Fable 5 63c0bb9e4a Page history by rows, coalescing a reply's deltas server-side
The scroll-up freeze-then-skip was the history pager fighting the transcript's
own storage. A streamed reply is stored one token per event -- hundreds of
AssistantText events for one message -- but a page was counted in events, so
on a delta-heavy conversation a page was a fraction of one row: the opening
80-event load was less than a screen, "scroll up a bit" hit the unloaded
boundary at once, and each page the client did fetch cost a 400-event fold
(hundreds of thousands of list copies) that landed as one jarring insertion.

The server now joins each run of consecutive AssistantText deltas into the one
event the client's fold makes of it, and counts a page's limit in these
coalesced rows -- so a page is a page of the screen whatever the delta density.
Measured against a 3,500-event / 100-row echo session on the emulator: a raw
limit-20 page returns 20 tokens of one reply; the coalesced limit-20 returns
five whole replies. Scrolling the whole thing showed waited p99 51.7ms -> 0.6ms
and the worst whole-transcript measure 59ms -> ~0, with the client folding ~100
row-events instead of 3,500 token-events. No duplicate keys; the first reply
still reconstructs whole from token zero, so healSplitMessage welds the raw
newest window to the coalesced older pages exactly as before.

Coalescing is opt-in per request (`?coalesce=true`) and applied only to older
pages (`before` set): the newest window keeps real seqs because the live stream
resumes from the newest seq the phone applied, and a coalesced newest event
would hide the deltas after its first seq and replay them. The anchor-restore
path also stays raw -- it counts events to reach a known seq, which a page
measured in rows cannot do -- so HISTORY_PAGE is now rows while the restore
span and its cushion stay in events.

Also: ui-sandbox.sh gains a `keep` verb that restarts the server without wiping
sessions, so a fixture that costs minutes to build (a long delta-heavy
transcript) survives a server rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 21:43:18 -04:00

316 lines
12 KiB
Bash
Executable File

#!/bin/sh
# An ai-server with invented sessions in it, for driving the phone UI.
#
# The import screen lists whatever Claude Code has on the machine, and in
# this VM that is real agent transcripts -- so exercising *delete* against
# the ordinary server means deleting somebody's conversation, and exercising
# *import* means starting a real `claude --resume` on the owner's account. Both
# are the wrong price for looking at a list.
#
# So this starts a second server that can see neither. `$HOME` is pointed at
# a sandbox directory, which is the only thing the importer's own script
# consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session
# data live there too. What it lists is invented here, and deleting all of
# it costs nothing.
#
# Three things are deliberately shared with the real server, because the
# installed APK is built against them: the TLS certificates (the app pins
# that CA and would refuse a fresh one) and the port. Run it while the real
# server is down.
#
# Usage:
# ./ui-sandbox.sh start it, print the enrolment command
# ./ui-sandbox.sh stop stop it
#
# Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT,
# AI_SANDBOX_DELAY -- the server's own `--delay`, which is what makes a
# spinner visible at all -- and AI_SANDBOX_SPAWN_DELAY, which holds an
# import open for that many seconds. On loopback every request is back in
# under a millisecond, so a busy state that is correct is still a busy state
# nobody can see.
set -eu
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}
# 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.
stop_server() {
[ -f "$PIDFILE" ] || return 0
pid=$(cat "$PIDFILE")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
echo "sandbox: stopped server $pid"
fi
rm -f "$PIDFILE"
}
case "${1:-start}" in
stop)
stop_server
exit 0
;;
# 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) ;;
# Restart the server but keep the sessions and enrolment already there, so a
# fixture built over minutes (a long delta-heavy transcript, say) survives a
# rebuild of the server binary. Plain `start` wipes them, which is right for
# the list-screen fixtures but wrong when the session under test was expensive
# to make.
keep) KEEP=1 ;;
*)
echo "ui-sandbox.sh: unknown command '$1' (start, keep, 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
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
if [ -z "${KEEP:-}" ]; then
rm -rf "$ROOT/home" "$ROOT/sessions"
fi
rm -f "$ROOT/config.ron"
mkdir -p "$PROJECTS" "$ROOT/sessions"
if [ -z "${KEEP:-}" ]; then
# Eight of them, because the point of the screen is a list long enough that
# picking rows one at a time is the annoyance being fixed. Ids are the same
# shape the CLI writes (a uuid, and the file name *is* the session id), and
# each carries a `cwd` and a few user turns so the row has a title, a path
# and a line count to show.
i=1
while [ "$i" -le 8 ]; do
id="0000000${i}-5eed-4a11-9c0d-000000000${i}00"
file=$PROJECTS/$id.jsonl
cwd="/home/bob/repos/sandbox/project-$i"
: >"$file"
turn=1
while [ "$turn" -le $((i + 2)) ]; do
printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \
"$cwd" "$i" "$turn" >>"$file"
turn=$((turn + 1))
done
# A usage record on the last line, which is where the importer reads the
# context figure from. Left off two of them on purpose: "no turn has
# recorded any" is a state the row has to be able to show, and a list
# where every row has a number never exercises it.
if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then
printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \
"$((i * 9000))" >>"$file"
fi
i=$((i + 1))
done
# A CLI that does nothing, so importing one of these is free and safe.
# Everything the spawn path cares about is here: it holds the fifo open,
# records a real pid, writes nothing, and dies on a signal. A real
# `claude --resume` against an invented session id would either fail in a
# way that tests nothing or start a turn on somebody's account.
cat >"$ROOT/fake-claude" <<FAKE
#!/bin/sh
# Slow to start, on purpose. An import against this finishes in
# milliseconds otherwise, so every state on the way -- the row marked
# "importing", the queue behind it, the event that clears them -- is over
# before anything can observe it, and a broken one looks exactly like a
# working one. AI_SANDBOX_SPAWN_DELAY is how long that window is held open.
sleep $SPAWN_DELAY
cat > /dev/null
FAKE
chmod +x "$ROOT/fake-claude"
# One big one, because size is what makes importing take any time at all.
# A spawn replays the whole file into this app's transcript, so against the
# four-line sessions above it is over in milliseconds and every state on the
# way is unobservable -- which is how a row that should have been marked
# "importing" went unnoticed for not being marked at all. AI_SANDBOX_BIG_MB
# sets how large.
big=$PROJECTS/0000000b-5eed-4a11-9c0d-00000000b000.jsonl
awk -v mb="$BIG_MB" 'BEGIN {
target = mb * 1000000
line = "{\"type\":\"user\",\"cwd\":\"/home/bob/repos/sandbox/big\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"a long sandbox turn, number %d, with enough text on it that the file reaches a realistic size rather than a token one\"}]}}"
written = 0
for (i = 1; written < target; i++) {
out = sprintf(line, i)
print out
written += length(out) + 1
}
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
}' > "$big"
fi
cat >"$ROOT/config.ron" <<RON
tokens: [
(
name: "sandbox",
sha256: "$hash",
),
$salvaged],
setups: [
(
id: "local",
name: "sandbox",
providers: [
(
name: "echo",
kind: echo,
),
(
name: "claude-cli",
kind: claude_cli,
command: "$ROOT/fake-claude",
models: [
"haiku",
],
),
],
),
],
sessions: [],
RON
echo "sandbox: building"
(cd "$SERVER_DIR" && cargo build --quiet)
# Fully detached, so it outlives the shell that started it. HOME is the
# whole isolation: the importer's script reads it, and nothing else here
# looks outside the paths passed explicitly below.
HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
--bind 127.0.0.1 \
--port "$PORT" \
--config "$ROOT/config.ron" \
--data-dir "$ROOT/sessions" \
--models-dir "$ROOT/models" \
--certs "$CERTS" \
--delay "$DELAY" \
>"$LOG" 2>&1 &
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
# that has nothing to do with what is being tested.
tries=0
while [ "$tries" -lt 50 ]; do
if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi
kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; }
tries=$((tries + 1))
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 (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
keep sessions across a restart (e.g. after rebuilding the server):
./ui-sandbox.sh keep
stop it:
./ui-sandbox.sh stop
INFO