diff --git a/packages/mosaic/framework/tools/tmux/send-message.sh b/packages/mosaic/framework/tools/tmux/send-message.sh index d397907f..484ca6ce 100755 --- a/packages/mosaic/framework/tools/tmux/send-message.sh +++ b/packages/mosaic/framework/tools/tmux/send-message.sh @@ -97,13 +97,34 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" - # would otherwise accumulate forever. sleep 0.5 -# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is -# still a draft. Success requires positive evidence — the queued banner, OR the -# REPL input box located AND clear of our message tail. The historical bug was -# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched -# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered" -# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only -# upgrade to delivered on positive evidence; anything we cannot confirm fails loud. +# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt +# glyph. The historical bug was treating ABSENCE of a draft as delivery; the +# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates +# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a +# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a +# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on +# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has +# LEFT it AFTER — that transition is positive proof of submission and needs no +# glyph. Absence alone still never means delivered: if we never saw our draft on +# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never +# leaves the input line stays a DRAFT (exit 2), preserving both historical guards. +_cursor_line() { # echo the pane's current input (cursor) line, glyph-free + local cy line + cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1 + [ -n "$cy" ] || return 1 + "${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p" +} +_draft_on_input() { # true iff our message tail is sitting on the input line now + [ -n "$snippet" ] || return 1 + printf '%s' "$(_cursor_line)" | grep -qF "$snippet" +} + +# Baseline: after the paste, our draft must be on the input line. This is positive +# proof we are on the right pane and the paste landed — the anchor the transition +# check measures against. +saw_draft=0 +_draft_on_input && saw_draft=1 + status="unconfirmed" for attempt in $(seq 1 $((RETRIES + 1))); do "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter @@ -113,20 +134,26 @@ for attempt in $(seq 1 $((RETRIES + 1))); do if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then status="queued"; break fi - # Locate the REPL input box (prompt glyph). If we cannot see it, we have NO - # evidence of submission state — stay UNCONFIRMED and retry; never infer delivery. + # POSITIVE draft evidence from a located prompt box, when one exists. This is the + # cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose + # foreground process never reads stdin) echoes our paste via the kernel line + # discipline and moves the cursor off it on Enter, which is indistinguishable from + # a real submit by cursor row alone. If a prompt box IS locatable and still carries + # our tail, that is affirmative proof the message was not consumed. Absence of a + # glyph is still never used for anything — that inference is the original E7 bug. promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1) - if [ -z "$promptline" ]; then - status="unconfirmed"; continue - fi - # Input box located AND still carrying our tail => unsubmitted draft. Flush + retry. - # (Submitted messages scroll up into history; a draft stays on the ❯ line.) - if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then + if [ -n "$promptline" ] && [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then status="draft"; continue fi - # Input box located AND clear of our tail => positively submitted. This is the - # only path to success besides the queued banner. - status="delivered"; break + if [ "$saw_draft" = 1 ]; then + if _draft_on_input; then + status="draft"; continue # still on the input line => not submitted; flush + retry + fi + status="delivered"; break # left the input line => positively submitted + fi + # No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence. + if _draft_on_input; then saw_draft=1; status="draft"; continue; fi + status="unconfirmed"; continue done [ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; } diff --git a/packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh b/packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh new file mode 100755 index 00000000..55d0ac13 --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind +# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt +# glyph is present. A pi seat renders a U+2500 rule input box with no ❯/^>/│ > +# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely +# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED", +# and the operator's rc=2-driven retry duplicates it. +# +# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN +# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket. +# +# Submission counting is EXACT and terminal-echo-independent: the fixture message +# is `echo >>SINK`; each real submission appends one line. wc -l SINK == +# number of times the REPL actually executed the send. This does not depend on how +# many times the marker string is painted on screen. +set -u +SEND="${SEND:?set SEND=/path/to/send-message.sh}" +SOCKET="glyphagnostic-$$" +TMP="$(mktemp -d)" +tmux() { command tmux -L "$SOCKET" "$@"; } +cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT +pass=0; fail=0 +ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); } +no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); } + +mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; } +subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count + +echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')" + +# --- A (control): glyph box (❯) that submits => exit 0, exactly one submission. +mk ctl '❯ ' +SINK="$TMP/sink.ctl" +out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4 +if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then + ok "control: ❯-box submits => exit 0, exactly one submission" +else no "control: ❯-box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi + +# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands +# (subs==1) yet shipping reports exit 2. Must be exit 0. +mk sub $'──────── \n' +SINK="$TMP/sink.sub" +out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4 +if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then + ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')" +else no "glyphless: U+2500 box that submits => exit 0" \ + "rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi + +# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the +# glyphless box, shipping => two submissions (the reported duplicate). The +# property: one logical send => exactly one submission. Same fix closes it. +mk dup $'──────── \n' +SINK="$TMP/sink.dup" +tries=0 +for attempt in 1 2; do + tries=$((tries+1)) + out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$? + sleep 0.4 + [ "$rc" = 0 ] && break # operator stops retrying only when told delivered +done +if [ "$(subs "$SINK")" = 1 ]; then + ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)" +else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi + +# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing. +# A cooked `sleep infinity` echoes the paste via the kernel line discipline and +# false-passes a cursor-row fix that is correct on real seats (measured). So: raw. +mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \ + "bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; } +mk_rawstuck estuck +SINK="$TMP/sink.estuck" +out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$? +sleep 0.3 +if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then + ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)" +else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi + +# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the +# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0. +mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \ + "bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; } +mk_busy ebusy +SINK="$TMP/sink.ebusy" +out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3 +if [ "$rc" = 0 ]; then + ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)" +else no "busy/queued-banner must report delivered" "rc=$rc"; fi + +# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft +# => must fail, never infer delivered from absence of a glyph/snippet. +if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then + no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0" +else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi + +echo "---"; echo "pass=$pass fail=$fail" +[ "$fail" = 0 ] diff --git a/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh b/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh index b1f9bbfe..4291dbc4 100755 --- a/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh +++ b/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh @@ -4,10 +4,13 @@ # # 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter # (text scrolls to history, box clears) => exit 0 "✓ delivered". -# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact -# historical FALSE POSITIVE: pre-patch it printed "✓ delivered" -# exit 0; post-patch it MUST fail loud (exit 2, stderr -# "could not confirm submission"). +# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi +# seat is this fixture (U+2500 rule, no glyph). Reshaped for +# #1257; see the note at the fixture for why the old exit-2 +# assertion was wrong. +# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI) +# => must fail loud. This carries the historical +# false-positive guard that fixture 2 used to be credited with. # 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the # input line) => exit 2, stderr "unsubmitted draft". set -uo pipefail @@ -37,19 +40,44 @@ else no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]" fi -# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this -# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2. +# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash). +# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted +# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong, +# and locking it in is what kept E7 alive: the pane submits, so "delivered" is +# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph +# — IS this fixture. Reporting exit 2 for it told operators a delivered message +# may be undelivered, and the retry that advice invites is the duplicate. +# +# The guard this fixture was reaching for is real and is NOT dropped: "never +# infer delivered from absence" is now enforced positively by fixture 2b below +# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box +# still carrying our tail => draft). Absence alone decides nothing either way. tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \ 'PS1="sh-noglyph$ " exec bash --noprofile --norc -i' sleep 0.3 -if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then - no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])" +out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$? +if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then + ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)" +else + no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]" +fi + +# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the +# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured +# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so +# there is no positive evidence of submission and the tool MUST fail loud. This +# is the historical false-positive guard, kept as a positive test. +tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \ + 'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"' +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then + no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])" else rc=$? - if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then - ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)" + if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then + ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'" else - no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]" + no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]" fi fi