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>
316 lines
12 KiB
Bash
Executable File
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
|