Files
Linux_post_install/AgentsReport/detective/2026-09-06_alias-menu-abort.md
T
Your Name 8ce54794ee
gates / consistency-and-conventions (push) Successful in 27s
fix: pos ai alias create aborts on empty system prompt — menu_ask_value --allow-empty
User report: pressing Enter on 'System prompt (empty = use built-in)'
silently returned to the menu — no alias created, and step labels read
[1/4] [2/4] in a 5-step flow.

Detective (pre-existing, not a 2026-09-06 regression): menu_ask_value's
documented contract is 'rc 1 = cancel, or empty answer with no default';
the step-4 call passed an empty default so the advertised empty answer
hit rc 1 and '|| return 0' aborted the flow. Same latent trap at the
alias-name step (empty-name warn/re-prompt was dead code). 11 other
call sites are correct (6 external rely on empty=cancel, 4 pass
defaults) — no global semantic change allowed.

Architect: opt-in --allow-empty flag on menu_ask_value (backward
compatible; empty+no-default -> rc 0 + empty value; genuine cancel/EOF
stays rc 1; default still wins). Builder: implemented in lib/menu-lib.sh
+ bin/pos-ai-alias (steps 1-2 relabeled /5, flag at the two approved
sites); 7-case smoke matrix PASS.

Tests: tests/t-menu-allow-empty.sh (30 checks) — semantics matrix
against the real menu_ask_value via non-TTY stdin, reader-contract
probes (empty-Enter rc 0 vs EOF rc 1), static guards on step labeling,
the exactly-2 flag call sites, edit-flow untouched, and a scope fence
over all pos-* tools. Pty E2E proven feasible (script -qec, 3 scenarios)
and documented in the Tester report; the E2E file itself remains a
follow-up.

Verified: make gen idempotent; make check OK; make lint 0 FAIL, 0 WARN;
make test 17 files / 299 checks / 0 fail / 0 skip (~49s); bash -n clean.
2026-09-07 02:04:30 -04:00

20 KiB

Detective Report — pos ai alias create: silent abort on empty System Prompt

  • Date: 2026-09-06
  • Scope: interactive UI failure in pos ai alias create flow (host ciya paste)
  • HEAD: 0b5043a
  • Role: read-only investigation (no project files modified)

TL;DR

The reported abort is confirmed and reproducible. In _alias_create() the "System prompt (empty = use built-in)" step calls menu_ask_value with no default, and menu_ask_value returns rc 1 for an empty answer when there is no default (lib/menu-lib.sh:357). The caller's || return 0 (bin/pos-ai-alias:410) then silently aborts to the menu — exactly the reported symptom. The Session step worked because it passes a default ($name), so empty→default→rc 0→proceeded.

  • Root cause (FACT): bin/pos-ai-alias:410 invokes menu_ask_value "System prompt (empty = use built-in)" "" — an empty 2nd arg means no default — while the UI text explicitly advertises empty as valid ("use built-in"). Empty→rc 1|| return 0→silent abort.
  • Blast radius: 7 distinct menu_ask_value call sites in pos-ai-alias; 6 outside. Per-site classification is in the table below. Only pos-ai-alias:353 and :410 show class-a (empty advertised as valid → defect). The edit flow is NOT affected (its prompt step explicitly handles empty; see edit verdict).
  • Step-count defect (FACT): create flow has 5 steps but lines bin/pos-ai-alias:352,383 hardcode /4 (should be /5), while 396/409/424 are /5. Introduced by commit 300b742a (feat: alias trust flag) which bumped the total only on the lines it touched.
  • Provenance (FACT): pos-ai-alias is byte-identical to HEAD; clean git status. Both defects are pre-existing, NOT regressions from the 2026-09-06 stabilization commits (d817c37, 0b5043a which only touched pos-network-download/pos-system-backup, not these call sites).
  • Fix constraint: the menu_ask_value "empty + no default → cancel" contract must be preserved (6+ callers rely on it). The fix must be local/opt-in in pos-ai-alias (e.g. treat empty as valid where the UI says so, or pass a sentinel default / use a menu_ask_value opt-in variant), never a global semantic change.

Report path (this file): AgentsReport/detective/2026-09-06_alias-menu-abort.md


Step 1: Trace the abort path (Task A) [DONE]

menu_ask_value contract (lib/menu-lib.sh:344-362)

350 menu_ask_value() {
351   local label="$1" def="${2:-}" val pr="$1"
352   [ -n "$def" ] && pr="$pr [$def]"
353   if ! val="$(menu_read_value "$pr")"; then
354     return 1                          # EOF / cancel
355   fi
356   if [ -z "$val" ]; then
357     [ -n "$def" ] || return 1          # <-- empty + no default => rc 1
358     echo "$def"
359     return 0
360   fi
361   echo "$val"
362 }

The documented contract at line 349 says: rc 0 value on stdout · rc 1 EOF/cancel, or empty answer with no default. This is exactly the f61766b0 lineage (the empty-no-default clause predates the 4306a53 paste-safe reader rewrite; 4306a53 only swapped the read -rp/menu_read_value internals, leaving the menu_ask_value empty clause untouched — see Step 5).

menu_read_value (the reader, 4306a53+)

menu_read_value returns rc 0 + "" on a plain empty Enter (submit=1, empty val, printed nothing), and rc 1 only on genuine EOF / Ctrl-C/Z/\ / Ctrl-D-on-empty (lines 272-280, 302-304). So cancel vs empty is distinguishable at the reader level — it is menu_ask_value that deliberately collapses empty-with-no-default into rc 1, folding it into the same code path as cancel.

_alias_create callers (bin/pos-ai-alias)

  • Line 410 (System Prompt): prompt="$(menu_ask_value "System prompt (empty = use built-in)" "")" || return 0
    • 2nd arg is ""def empty → no default.
    • UI text promises empty is valid ("use built-in").
    • empty → rc 1|| return 0silent abort to menu. ✔ matches paste.
  • Line 397 (Session): session="$(menu_ask_value "Session name" "$name")" — passes default $name (non-empty for any valid name). empty→default→rc 0→proceeded. ✔ matches paste (session step proceeded).
  • Line 353 (Alias name): name="$(menu_ask_value "Alias name" "")" || return 0 — no default; empty→rc 1→abort. The loop's empty-name warn/re-prompt at line 355 is unreachable for the empty case.

Empirical confirmation

The reader needs a TTY (stty raw mode). I drove a non-TTY fallback path (which shares the exact menu_ask_value empty/no-default logic — stty -g fails → plain readmenu_ask_value still hits line 357) to confirm the contract:

$ echo "" | menu_ask_value "System prompt (empty = use built-in)" ""
empty-no-default -> rc=1 out=<>
$ echo "" | menu_ask_value "Session name" "assist"
empty-with-default -> rc=0 out=<assist>
$ printf '' | menu_ask_value "Alias name" ""
EOF-no-default -> rc=1 out=<>

This deterministically reproduces the exact divergence: empty-with-default succeeds (rc 0, default returned), empty-no-default aborts (rc 1). A full pty repro of the raw-mode reader is possible (pty available) but adds no discriminating evidence beyond this.

A note on the harness: a naive prompt="$(menu_ask_value ...)" under set -e in the harness exits immediately on the rc-1 cmdsubst — itself a live demonstration that the failing assignment is the abort point.

Task A conclusion

Findings 1-2 confirmed. The report's abort path maps exactly to menu_read_value returning empty (rc 0) on plain Enter, then menu_ask_value returning 1 for empty-with-no-default, then || return 0 in _alias_create.


Step 2: Blast radius — every caller of menu_ask_value (Task B) [DONE]

pos-ai-alias (7 sites)

line code class evidence
353 menu_ask_value "Alias name" "" a (defect) UI "Alias name"; loop expects to re-prompt on empty (355 warn) but rc 1 short-circuits. Loop's empty-warn is dead code.
397 menu_ask_value "Session name" "$name" ok default $name; empty→default→rc 0. Correct.
410 menu_ask_value "System prompt (empty = use built-in)" "" a (defect) UI explicitly says empty valid; empty→rc 1→abort. The reported bug.
433 menu_ask_value "Trust this alias? (y/N)" "N" ok default N; empty→N→rc 0. Correct.
538 menu_ask_value "Session name" "$new_session" (edit) c (edge) default = current session. If a stored session were empty (shouldn't happen post-create, create defaults to $name), empty→rc 1→return 0→abort. Requires an empty-stored-session precondition. See edit verdict.
557 menu_ask_value "System prompt" "$default_display" (edit) handled edit explicitly guards: when default_prompt empty, line 560 sets tmp_prompt="" instead of aborting. No bug.
585 menu_ask_value "Trust this alias? (y/N)" "$cur_trust_label" (edit) ok default = current label, non-empty. Correct.

Outside pos-ai-alias (6 call sites, 4 files)

file:line code class evidence
pos-system-backup:231 menu_ask_value "Folder to back up" (no default) b (cancel, correct) empty→rc 1→return 0; a real folder must be typed, or user backs out. menu_backup_folder returns 0 on empty. Correct today.
pos-media-sync:67 menu_ask_value "Source folder [current: $SRC]" (no default; $SRC is in the label, not 2nd arg) b (cancel, correct) empty→rc 1→return 0; empty not a valid folder (checked at 68-70). Correct.
pos-network-download:949 menu_ask_value "$1" "N" (menu_ask_yn, default N) ok empty→N→rc 0; rc 1 only genuine cancel. Correct.
pos-network-download:1001 menu_ask_value "URL to add (download dir: ...)" (no default) b (cancel, correct) empty→rc 1→return 0; line 1002 redundant `[ -n "$url" ]
pos-network-download:1027 menu_ask_value "Type purge to clear finished/error history" (no default) b (cancel, correct) empty→rc 1→return 0; must literally type purge. Empty=cancel is intentional (must-type confirmation). Correct.
pos-docker-vbox:495 menu_ask_value "Image ref" (no default) b (cancel, correct) comment 106-107: "empty answer and a dead stream are indistinguishable (menu_ask_value has no default there)". Typed-value add loop; empty = cancelled typing → back to picker. Intentional.
pos-docker-vbox:831 menu_ask_value "VM name" (no default) b (cancel, correct) comment 107/828-830: empty answer intentionally tears down loudly with nothing created ("both rc 1 ... both take the loud teardown"). Intentional.

Blast-radius conclusion

Only pos-ai-alias:353 and :410 are class-a defects. The other 6 external call sites and the remaining pos-ai-alias sites are correct by design (empty=cancel or empty=default). A global change to menu_ask_value's empty-no-default semantics would break at least these correct sites: pos-system-backup:231, pos-media-sync:67, pos-network-download:1001/1027, and both pos-docker-vbox sites (explicitly documented). The fix must be local to pos-ai-alias.


Step 3: Step-count defect + edit flow (Task C) [DONE]

Create step numbering (bin/pos-ai-alias)

Confirmm via grep -n 'step [0-9]':

352: step 1 4   (create Alias Name)
383: step 2 4   (create Provider)
396: step 3 5   (create Session)
409: step 4 5   (create Prompt)
424: step 5 5   (create Trust)

Create has 5 steps, but lines 352/383 print /4 while 396/409/424 print /5. Confirms the cosmetic [1/4] [2/4] mismatch in the paste.

Introducing commit: git blame shows 352 = 9f289ba3, 383 = 9f289ba3, 396/424 = 300b742a. 300b742a ("feat: alias trust flag") added the Trust step and set 396/409/424 to /5 but did not update 352/383, leaving them at /4.

Edit flow

grep:

520: step 1 4   (edit Provider)
537: step 2 4   (edit Session)
556: step 3 4   (edit Prompt)
581: step 4 4   (edit Trust)

Edit numbering is internally consistent (4 steps, all /4). No mis-numbering.

Edit empty-input traps: verified the edit Prompt step (556-573) explicitly guards the empty-original-prompt case:

557 if ! tmp_prompt="$(menu_ask_value "System prompt" "$default_display")"; then
559   # EOF/cancel: empty-answer abort only when there IS a default;
560   # Enter on an empty original prompt keeps it empty and continues.
560   [ -z "$default_prompt" ] && tmp_prompt="" || return 0

So when the stored prompt is empty (default_display empty), empty Enter → tmp_prompt="" → continues (no abort). When the stored prompt is non-empty, empty Enter → returns the default (rc 0). Edit prompt step has NO bug — it is the correct pattern the create flow's Step 4 should have used.

Minor/edge: edit Session step (538) aborts on empty Enter only if the stored session is empty, which the create flow prevents (line 404 defaults session to $name). Classified c (ambiguous/edge), low practical impact, no action required.


Step 4: Provenance — byte-identical to HEAD, pre-existing (Task D) [DONE]

  • git status --shortclean (empty). Working tree matches HEAD 0b5043a.
  • git diff HEAD -- bin/pos-ai-alias lib/menu-lib.shempty. No uncommitted edits.
  • git log --format=%H ... -- bin/pos-ai-alias → 5 commits, all Aug 26-27 2026: 9f289ba (feat: pos ai alias), e969234, 59935dc, 300b742 (trust flag), 4306a5 (paste-safe reader). None are the 2026-09-06 stabilization commits.
  • The 2026-09-06 commits d817c37 and 0b5043a touched bin/pos-network-download and bin/pos-system-backup but not any menu_ask_value call site in those files (their diffs contain no menu_ask_value additions/deletions/-context edits to those lines — verified by grepping the diffs). They never touched pos-ai-alias or lib/menu-lib.sh.
  • Introducing commit for both defects:
    • menu_ask_value empty-no-default → rc 1 contract: f61766b0 (Aug 24) in lib/menu-lib.sh (lines 356-357 unchanged since then; verified by blame).
    • The pos-ai-alias mis-use (empty advertised as valid + || return 0) for step 4 and the alias-name step: 9f289ba3 (Aug 26) originally; the abort remains in tree to HEAD.
    • The create step-count (/4 vs /5) mismatch: 300b742a (Aug 27, trust flag).

Verdict: pre-existing; NOT a regression from the 2026-09-06 stabilization/fix commits.


Step 5: Fix constraints (Task E) [DONE]

A correct fix must preserve:

  1. Cancel vs empty must remain distinguishable. The reader already distinguishes them (Ctrl-D/EOF/Ctrl-C → rc 1; plain Enter → rc 0 + empty). The semantic collapse happens only in menu_ask_value (empty-no-default → rc 1). The fix must not blur this line.
  2. The 6 external callers relying on empty=cancel must keep that behavior: pos-system-backup:231, pos-media-sync:67, pos-network-download:1001/1027 and both pos-docker-vbox sites (documented comments 106-107 and 828-830).
  3. No global semantic change to menu_ask_value default contract ("empty with no default = rc 1"). Any change there ripples to the correct callers above.
  4. The fix must be local/opt-in to pos-ai-alias: at lines 353 and 410, the UI explicitly advertises empty as valid, so empty must proceed as an empty value instead of aborting. Candidate shapes for Builder (not decided here — implementation is out of the Detective's read-only scope): pass a sentinel default, add an opt-in menu_ask_value flag to keep empty, or restructure the || return 0 into || if cancel then return; else continue with empty.
  5. Step-count fix (create): lines 352/383 /4/5 to match the 5-step flow (edit is already consistent at 4).
  6. Whatever the fix, the edit-flow Pattern (empty-with-default guard, lines 557-560) shows the codebase's intended way to handle optional-input prompts.

Confirmed Root Cause Statements

ROOT CAUSE (FACT): In _alias_create(), the "System prompt (empty = use built-in)" step (bin/pos-ai-alias:410) calls menu_ask_value with an empty second argument (no default), and the shared helper lib/menu-lib.sh:356-357 returns rc 1 for an empty answer when there is no default — conflating "user typed nothing" with "user cancelled". The caller's || return 0 (bin/pos-ai-alias:410) then silently aborts the create flow back to the menu with no alias created. The same latent trap exists at bin/pos-ai-alias:353 (alias name), where the loop's empty-name warn/re-prompt (:355) is dead/unreachable; the UI at both 353 and 410 explicitly advertises empty as acceptable ("use built-in").

Related defect (FACT): create-flow step numbering is inconsistent — line 352 and 383 print /4 while lines 396/409/424 print /5, so the wizard shows [1/4] [2/4] [3/5] [4/5] [5/5] for its 5 steps.

Provenance (FACT): pre-existing (not a 2026-09-06 stabilization regression); pos-ai-alias is byte-identical to HEAD on a clean tree. Introducing commits: 9f289ba3 (abort mis-use), 300b742a (step-count mismatch), with the menu_ask_value empty-no-default contract from f61766b0.

Blast Radius Table

caller line class evidence
pos-ai-alias create Alias name 353 a (defect) empty advertised in flow; loop warn (355) unreachable
pos-ai-alias create Session 397 ok default $name; empty→default→rc 0
pos-ai-alias create Prompt 410 a (defect — reported) UI "(empty = use built-in)" is valid; aborts
pos-ai-alias create Trust 433 ok default N
pos-ai-alias edit Session 538 c (edge) aborts only if stored session empty (prevented by create)
pos-ai-alias edit Prompt 557 handled explicit empty guard at 560
pos-ai-alias edit Trust 585 ok default = current label
pos-system-backup Folder 231 b (cancel, keep) empty must not be a folder; empty=cancel correct
pos-media-sync Source 67 b (cancel, keep) label carries $SRC, not a default; empty=cancel correct
pos-network-download menu_ask_yn 949 ok default N
pos-network-download add URL 1001 b (cancel, keep) empty→rc 1→return 0; redundant 1002 guard
pos-network-download purge 1027 b (cancel, keep) must type purge; empty=cancel intentional
pos-docker-vbox image ref 495 b (cancel, keep) comments 106-107; empty=cancel intentional
pos-docker-vbox VM name 831 b (cancel, keep) comments 107, 828-830; empty→loud teardown intentional

Step-Count Verdict

Create flow is 5 steps but prints /4 on steps 1-2 (353/383). Edit flow is internally consistent at 4. Introduced by 300b742a.

Edit-Flow Verdict

No bug: edit Prompt (557-560) explicitly handles the empty-original-prompt case and empty-with-default correctly; edit numbering is consistent. Only theoretical edge is a stored-empty session, prevented by create (404) and class-c.

Provenance Verdict

Pre-existing. Clean tree, byte-identical to HEAD. Not a 2026-09-06 regression. Introducing commits: f61766b0 (contract), 9f289ba3 (abort mis-use), 300b742a (step-count + trust step).

Fix Constraints

  1. Preserve cancel-vs-empty distinction (reader already separates them; do not blur).
  2. Preserve empty=cancel for the 6 external call sites (pos-system-backup:231, pos-media-sync:67, pos-network-download:1001/1027, pos-docker-vbox:495/831 — two explicitly documented).
  3. NO global semantic change to menu_ask_value's empty-no-default→rc 1 contract.
  4. Fix local/opt-in to pos-ai-alias 353/410: empty must be accepted as an empty value where the UI says it's valid.
  5. Fix create step numbering: 352/383 → /5.
  6. Follow the edit-flow pattern (empty-with-default guard) as the codebase's intended shape for optional-input prompts.

Handoff

Status: ROOT_CAUSE_ESTABLISHED

Symptom: pos ai alias create aborts back to menu after the empty "System prompt"
         step — no [5/5] trust step, no confirmation, no alias created; with a
         cosmetic [1/4] [2/4] vs [3/5]-[5/5] step count.

Expected: empty system prompt (advertised "use built-in") proceeds to the trust
          step and creates the alias with an empty prompt.

Actual: empty + no default → menu_ask_value rc 1 → `|| return 0` → silent abort.

Root cause: lib/menu-lib.sh:356-357 returns rc 1 for empty-with-no-default,
            collapsing "empty" into "cancel"; bin/pos-ai-alias:410 (and 353)
            advertise empty as valid but pass no default, and the `|| return 0`
            converts rc 1 into a silent abort.

Classification: FACT

Evidence: code trace (menu-lib.sh:344-362; pos-ai-alias:344-467) + deterministic
          non-TTY repro (empty-no-default→rc 1; empty-with-default→rc 0 default);
          clean HEAD tree; git blame provenance.

Tests performed: menu_ask_value empty/EOF/with-default matrix; created/edit flow
                 step numbering; provenance (git status/diff/blame, stabilization
                 commit diffs).

Alternatives eliminated: not a reader (menu_read_value) bug — reader distinguishes
          empty(rc 0) from cancel(rc 1); not a global menu_ask_value bug needing
          semantic change — 6 external callers rely on empty=cancel; not a
          2026-09-06 regression — those commits didn't touch these lines.

Affected components: bin/pos-ai-alias (create 353/410; step numbers 352/383);
                     lib/menu-lib.sh (contract, no change needed).

Scope / decision boundary: implementation of the fix (how to make 353/410
          opts accept empty) is Builder territory; whether to add an opt-in
          menu_ask_value variant or a sentinel is an implementation decision.

Recommended next agent: Builder

Reason: root cause and blast radius are established; a local, opt-in fix in
        pos-ai-alias is understood and within approved scope, preserving the
        empty=cancel contract for the 6 external callers.

Changes made by Detective: none (read-only).