fix: pos ai alias create aborts on empty system prompt — menu_ask_value --allow-empty
gates / consistency-and-conventions (push) Successful in 27s

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.
This commit is contained in:
Your Name
2026-09-07 02:04:30 -04:00
parent 0b5043a9f3
commit 8ce54794ee
8 changed files with 893 additions and 7 deletions
+2
View File
@@ -42,6 +42,8 @@ summary (newest last).
## Done
- **2026-09-06** — `pos ai alias` create-flow silent abort + bogus step labels. User hit: pressing Enter on "System prompt (empty = use built-in)" silently returned to the menu (no alias created); step counters showed `[1/4] [2/4]` in a 5-step flow. Detective: `menu_ask_value` (lib/menu-lib.sh) contract returns rc 1 for empty+no-default, collapsing "empty" with "cancel"; the Step 4 call at `:410` passed `""` default so the advertised empty answer triggered `|| return 0` → silent abort; same latent trap at alias-name `:353` (re-prompt dead code). Pre-existing (introduced with the alias feature `f61766b0`/`9f289ba3`/`300b742a`), NOT a 2026-09-06 regression; 11 other `menu_ask_value` callers 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 → rc 1, default wins) + step labels fixed to `/5`. Builder: implemented (lib/menu-lib.sh flag+docs, bin/pos-ai-alias `:353`/`:410` + step counters), 7-case smoke matrix PASS, gen idempotent, check OK, lint 0 FAIL/0 WARN. User chose to commit without the Tester regression round (report: `AgentsReport/tester/2026-09-06_alias-menu-tests.md` — pty feasibility proven, steps 2-6 pending).
- **2026-09-06** — AI server breakage post-llamacpp install — four root causes found and fixed (Detective→Architect→Builder→Tester chain). (1) `llama-server --version` prints to STDERR — `detect_llama_version`'s `2>/dev/null` swallowed it → "installed llama.cpp unknown"; (2) `printf|grep -q` under `pipefail` → SIGPIPE rc=141 race randomly rejecting valid flags from the 59 KB `--help`; (3) `resolve_model` expected flat files but the HF downloader creates `<models>/<repo>/file.gguf` dirs → `Model not found`; (4) llama.cpp default port 8080 vs tool's 8088. Architect DQ1-DQ6: help-gated validation stays; dir-expansion never silently picks; `ensure_user_bus` in `lib/common.sh` pre-flights all three tools; `--no-unit` direct-run escape hatch for SSH/headless; candidates narrowed to `llama-server`/`llama-server-cuda`; port pinned 8088; installer post-install sanity. Builder F1-F7 (stderr version capture, pipefail-safe flag validation, model dir expansion, port hardcoding, `--no-unit`, user-bus pre-flight, installer sanity + F1 regex edge: `build 1.2.3``1` misparse). Tester: 4 new regression files (version-from-stderr, 20× flag-validation determinism, model dir expansion, bus pre-flight + E2E) + 3 fixture updates; suite now 16 files / 269 checks. Verified: `make gen` idempotent, `make check` OK, `make lint` 0 FAIL / 0 WARN, `make test` 269/269 (~49 s), `bash -n` clean, `git diff --check` clean. Post-fix: user's machine needed only `export XDG_RUNTIME_DIR=/run/user/1000` (linger already on) → Option A systemd-managed server works, or `--no-unit` for direct run.
- **2026-09-06** — Stabilization pass: 17-point code-level audit executed via Explorer(3) → Architect(decisions D-A..D-F) → Builder(ai/security/tooling/config/netprobe/f1-f3) → Tester(regression suite) → Reviewer(2 rounds). Security: Telegram sender-owner AND-gate + `TELEGRAM_OWNER_ID` registry/docs; Matrix `MATRIX_ROOM_ID` required; gpg `--passphrase-fd 3` (no argv secret); `/dev/tcp` positional-arg form (checkport/smb-client/share-lib/NET_PROBE incl. escaping `\$1`/`\$2`); eval `--no-command-execution` now carried by both chat bridges (D-B), deny-by-default `[y/N]`, tty-gated `--trust`; D-A soft-fail model ratified by Architect amendment (fail-closed either way; listeners are `Restart=always` so strict mode would crash-loop). AI: ALL ExecStart flags validated against installed llama.cpp (requested→error, default→omit+warn, `CONFIG_REQUESTED_FLAGS`), single-line ExecStart confirmed via `systemd-analyze verify`; hf single-file failure rc/exit-0 + meta-write bug fixed; `LLAMACPP_HOST` coherent; `# POS_SUBCMDS` + metadata gaps closed. Tooling: lint-conventions rewritten Bash-native (~24-30× faster, rules byte-identical, `:num` restored on 2 WARNs, planted-violation negative verified); `pos system uninstall` covers all 12 libs + scale-tail + flags dir + systemd USER units (`|| true`) + de-hardcoded plugin markers; safe anchored `.bash_completion`/`.bashrc` removals replace `sed -i '/pos/d'`. Config: canonical `load_env_file` in `lib/config-ui.sh` (CRLF strip, env-wins, XDG, `LOADED_ENV_KEYS`); 9 tools migrated; entertainment-lib collapsed to wrappers; docker-compose deliberately NOT migrated (source-semantics, documented). Tests: first committed regression suite — `tests/run-tests.sh` zero-dep runner + `make test`; 12 files / 179 checks / 0 skip / ~52s; hard skip contract (never lie); negative lint/gen-drift gates; systemd-analyze verify included. Verification: `make gen` idempotent, `make check` OK, `make lint` `0 FAIL, 0 WARN`, `make test` green, `bash -n` clean, `git diff --check` clean; `systemd-analyze verify` PASS on generated unit; CLI smokes (`pos --help`, `pos ai --help`, `pos ai hf --help`, `pos ai server --help`, `pos tree`) OK.
@@ -0,0 +1,261 @@
# Alias Menu Abort — Fix Design (2026-09-06)
## TL;DR
- **Decision 1 (chosen):** Add an opt-in `--allow-empty` flag to `lib/menu-lib.sh` `menu_ask_value` — "empty answer with no default returns `rc 0` + empty value; only genuine cancel (reader `rc 1`) returns `rc 1`". Default behavior is unchanged, so the 6 external empty=cancel call sites keep their contract. **This is Option A.**
- **Decision 2:** Switch exactly two `pos-ai-alias` create call sites to `--allow-empty`: Alias-name (line 353) and System-prompt (line 410).
- **Decision 3:** Fix the create step-count cosmetic defect: lines 352 and 383 `/4``/5`.
- **Decision 4:** Update the lib doc-comment / function-index to document `--allow-empty` (Builder-in-scope; it is a source edit, not a design artifact).
- **Test scope:** `menu_ask_value --allow-empty` is testable coverage-free via the non-TTY stdin path (`echo "" | ...`); end-to-end create-flow verification is a TTY-level manual check (note for Tester).
- **Scope fence:** NO change to the 6 external call sites, NO change to edit flow, NO global semantic change to `menu_ask_value` default contract, no refactor.
---
## Decision 1: Add opt-in `--allow-empty` to `menu_ask_value` `[DECIDED]`
**Problem solved:** The reader (`menu_read_value`) already distinguishes empty (`rc 0` + empty) from genuine cancel/EOF (`rc 1`). `menu_ask_value` collapses empty-with-no-default into `rc 1`. Two `pos-ai-alias` call sites advertise empty as valid but hit that collapse.
**Option evaluated (A chosen):** add `menu_ask_value [--allow-empty] <label> [default]`.
**Option B (local workaround) — rejected:**
- A local re-implementation (e.g. call `menu_read_value` directly + replicate the default-resolution) **duplicates lib logic** (`menu_ask_value` lines 351-361) and risks divergence when the lib evolves.
- A sentinel default + caller-side mapping is **fragile** (some impossible-to-type sentinel must be picked, mapped back, and could still theoretically be typed) and unreadable.
- **Does not scale:** the next tool needing empty-valid would re-invent it; the flag exists once in the lib and every future caller reuses it.
- Neither preserves the alias-name re-prompt UX as cleanly.
**Rationale for A:**
1. **Backward compatible:** default contract (empty + no default → `rc 1`) is untouched for all existing callers — `--allow-empty` only widens behavior for callers that opt in. Satisfies the hard constraint (6 external empty=cancel sites unaffected).
2. **Minimal surface:** one keyword param, two lines of parse, one line of behavior. Not a refactor.
3. **Preserves the cancel/empty distinction:** genuine cancel still returns `rc 1` even with the flag; only empty-answer-with-no-default flips to `rc 0`.
4. **Scales:** standard opt-in shape; `menu_ask_value` currently has no flags, so this defines the cleanest minimal first flag (leading keyword, parsed before the positional label — no ambiguity since no call site's label equals `--allow-empty`).
5. **Matches intended UX:** the alias-name re-prompt (dead at line 355 today) becomes reachable; the system-prompt "use built-in" promise becomes true.
### Exact interface spec (lib/menu-lib.sh)
Signature:
```bash
menu_ask_value [--allow-empty] <label> [default]
```
- `--allow-empty` is an optional leading keyword flag. When present it is consumed and shifted before parsing `<label>`/`[default]` positionals.
- Behavior table:
| input | no flag | `--allow-empty` |
|-------|---------|-----------------|
| reader rc 1 (cancel/EOF/Ctrl-C) | rc 1 | rc 1 (unchanged — cancel stays cancel) |
| empty value, default present | rc 0, echo default | rc 0, echo default (default still wins) |
| empty value, no default | rc 1 (unchanged) | **rc 0, echo empty** |
| non-empty value | rc 0, echo value | rc 0, echo value |
- `--allow-empty` only widens the empty+no-default cell; it never suppresses the default and never converts genuine cancel.
- Doc-comment updates required in the same file:
- Function index line 27: `menu_ask_value <label> [default]``menu_ask_value [--allow-empty] <label> [default]`.
- Function doc block (lines 344-349): add a line documenting `--allow-empty` with the exact semantics above (empty+no-default → rc 0 empty; cancel still rc 1).
Reference implementation (approval shape — Builder reproduces this exactly):
```bash
menu_ask_value() {
local allow_empty=0
if [ "${1:-}" = "--allow-empty" ]; then
allow_empty=1
shift
fi
local label="$1" def="${2:-}" val pr="$1"
[ -n "$def" ] && pr="$pr [$def]"
if ! val="$(menu_read_value "$pr")"; then
return 1 # EOF / cancel
fi
if [ -z "$val" ]; then
[ -n "$def" ] && { echo "$def"; return 0; }
[ "$allow_empty" -eq 1 ] || return 1
echo ""
return 0
fi
echo "$val"
}
```
**[DECIDED]**
---
## Decision 2: Switch exactly 2 call sites to `--allow-empty` `[DECIDED]`
Only the two class-a (empty advertised as valid) defunct sites get the flag. All other 12 call sites across the repo stay byte-identical.
### bin/pos-ai-alias line 353 (Alias name)
From:
```bash
name="$(menu_ask_value "Alias name" "")" || return 0
```
To:
```bash
name="$(menu_ask_value --allow-empty "Alias name" "")" || return 0
```
Behavior after the change:
- Genuine cancel (Ctrl-C/EOF, reader rc 1) → `menu_ask_value` rc 1 → `|| return 0`**abort to menu** (correct; unchanged).
- Empty Enter → `menu_ask_value` rc 0 + `""``name=""` → line 355 `[ -z "$name" ]` fires → `warn "Alias name cannot be empty"``continue` → re-prompt (the intended UX, now reachable). **The re-prompt loop is preserved by design.**
- Non-empty / valid / collision cases: unchanged.
### bin/pos-ai-alias line 410 (System prompt)
From:
```bash
prompt="$(menu_ask_value "System prompt (empty = use built-in)" "")" || return 0
```
To:
```bash
prompt="$(menu_ask_value --allow-empty "System prompt (empty = use built-in)" "")" || return 0
```
Behavior:
- Genuine cancel → rc 1 → `|| return 0`**abort to menu** (correct; unchanged — cancel still exits the create flow).
- Empty Enter → rc 0 + `""``prompt=""` → passes the `|` check (empty has no `|`) → breaks → **proceeds with built-in prompt**. The UI text "(empty = use built-in)" now means what it says.
- Non-empty prompt: unchanged (validation/truncate/continuation identical).
No change to lines 397, 433 (create) or the edit-flow sites (538, 557, 585) — those are correct today.
**[DECIDED]**
---
## Decision 3: Fix create step-counter 352/383 `/4` → `/5` `[DECIDED]`
Create flow has 5 steps (Alias Name `352`, Provider `383`, Session `396`, Prompt `409`, Trust `424`), and lines 396/409/424 already print `/5`. Only 352 (`step 1 4`) and 383 (`step 2 4`) are stale. Set both to `/5`.
No change to the edit flow — internally consistent at 4 (lines 520/537/556/581 all `/4`).
**[DECIDED]**
---
## Decision 4: Lib source edit (doc/comment) is Builder scope `[DECIDED]`
The doc-comment and function-index updates to `lib/menu-lib.sh` are ordinary source edits. They are part of the approved scope for Builder (decision 1 embedded them). They are NOT an Architect artifact — the architect report (this file) is the design record; the inline doc comment is implementation.
---
## Scope Fence
**Approved outcome:** `pos ai alias` create flow no longer silently aborts on an empty System Prompt; empty alias-name re-prompts with the warn; create step numbers are consistent (5/5).
**In-scope components/files:**
- `lib/menu-lib.sh` — add `--allow-empty` flag + update doc-comment and function index.
- `bin/pos-ai-alias` — lines 352, 353, 383, 410.
**Allowed interface changes:**
- `menu_ask_value` gains optional leading `--allow-empty` (additive; no positional or rc semantics change for existing callers).
- `bin/pos-ai-alias` call sites 353/410 pass `--allow-empty`; step counters 352/383 → `/5`.
**Allowed behavior changes:**
- Empty System Prompt → proceeds (built-in), instead of aborting.
- Empty Alias Name → warn + re-prompt (was dead/unreachable code).
- Create step numbers 1 and 2 display `/5`.
**Required compatibility (untouched — must remain byte-identical):**
- All 6 external `menu_ask_value` call sites: `pos-system-backup:231`, `pos-media-sync:67`, `pos-network-download:1001/1027`, `pos-docker-vbox:495/831`.
- All edit-flow call sites (538/557/585) and create 397/433.
- The default `menu_ask_value` contract (empty + no default → rc 1, no flag).
- Genuine cancel at 353/410 still aborts the create flow (`|| return 0` retained).
**Explicitly out of scope:**
- No global semantic change to `menu_ask_value`.
- No change to `menu_read_value`.
- No change to the edit flow.
- No refactor of `_alias_create`; the re-prompt loop structure at 350-380 is retained.
- No tests written by Architect/Builder (Tester scope, see below); no docs beyond the inline menu-lib.sh comment.
**Architectural constraints:**
- Keep change surface minimal (two flag switches + two counters + one lib flag).
- Preserve cancel/empty distinction end-to-end.
- UI text "(empty = use built-in)" must keep its meaning (empty proceeds with empty).
---
## Test scope (for Tester)
`menu_ask_value --allow-empty` is covered **without a TTY** via the shared non-TTY stdin path (Detective already proved `echo "" | menu_ask_value ...` exercises the exact `menu_ask_value` logic). Recommended coverage matrix:
- `echo "" | menu_ask_value --allow-empty "l" ""` → rc 0, output empty.
- `echo "" | menu_ask_value "l" ""` → rc 1 (regression: default contract unchanged).
- `echo "" | menu_ask_value --allow-empty "l" "def"` → rc 0, output `def` (default wins even with flag).
- `printf '' | menu_ask_value --allow-empty "l" ""` → rc 1 (genuine EOF still cancel).
- `printf 'xyz\n' | menu_ask_value --allow-empty "l" ""` → rc 0, output `xyz`.
- `echo "" | menu_ask_value "l" "def"` → rc 0, output `def` (regression).
Plus one **TTY-level manual check** (needs a real terminal): run `pos ai alias` create → leave System Prompt empty → expect it to proceed to the Trust step and create the alias with empty prompt (built-in); leave Alias Name empty → expect `warn "Alias name cannot be empty"` + re-prompt; press Ctrl-C on any step → expect clean abort to menu.
If a shared non-TTY repro harness exists under `tests/`, add the flag matrix there; otherwise the manual TTY note stands. This is Tester's domain — Architect fixes only scope, not the writing.
---
## Verification expected
1. `bash -n` on `lib/menu-lib.sh` and `bin/pos-ai-alias`.
2. `make gen && make check && make lint` (definition of done: check green, lint `0 FAIL, 0 WARN`) — required because `bin/pos-ai-alias` is touched.
3. Tester runs the `--allow-empty` matrix (above) and the TTY create-flow check.
4. Confirm `git diff --exit-code` clean after `make gen` (determinism gate).
**Open risks / residual:**
- None architectural. The only residual is the class-c edge (edit Session aborts if a stored session is empty), which create-flow line 404 prevents today — out of scope, documented by Detective.
---
## Handoff
```text
Status: DECISION_READY
Problem:
pos ai alias create silently aborts on empty "System prompt" (and empty alias-name
re-prompt is unreachable) because menu_ask_value collapses empty+no-default into
rc 1; plus cosmetic /4 vs /5 step numbers.
Decision:
(A) Add opt-in `--allow-empty` to lib/menu-lib.sh menu_ask_value (empty+no-default
→ rc 0 + empty; cancel still rc 1; default unchanged). (2) Switch pos-ai-alias 353
and 410 to the flag. (3) Fix step counters 352/383 → /5.
Reasoning:
Reader already distinguishes empty from cancel; the collapse happens only in
menu_ask_value. Flag is opt-in (backward compatible, 6 external empty=cancel callers
untouched), scales for future callers, and makes dead re-prompt code reachable.
Option B (local re-implementation / sentinel) duplicates lib logic, risks divergence,
and does not scale.
Ownership:
Builder — edits lib/menu-lib.sh (flag + doc) and bin/pos-ai-alias (2 switches + 2 counters).
Interfaces:
menu_ask_value gains optional leading `--allow-empty`. No positionals, default, or
rc semantics change for existing callers. pos-ai-alias internal behavior: empty
system prompt proceeds built-in; empty alias name re-prompts.
Approved scope:
lib/menu-lib.sh (flag + inline doc; function-index line 27); bin/pos-ai-alias
lines 352, 353, 383, 410.
Explicitly out of scope:
6 external call sites, all edit-flow sites, 397/433, menu_read_value, global semantic
change, refactor of _alias_create, alias-name cancel semantics (still aborts via || return 0).
Constraints:
No global menu_ask_value semantic change. Cancel still aborts at 353/410. Empty
systems-prompt proceeds; empty alias-name re-prompts with warn. Keep change surface minimal.
Verification:
bash -n on both files; make gen && make check && make lint (0 FAIL, 0 WARN); Tester
--allow-empty matrix + TTY create-flow check; git diff --exit-code clean after gen.
Risks:
None architectural. Residual class-c (edit Session on stored-empty) is out of scope
and today prevented by create (line 404).
Recommended next agent:
Builder
Reason:
Design and exact call-site/libeay changes are fully specified; Builder can implement
without making architectural decisions. Tester follows for the matrix + TTY check.
Architect changes:
AgentsReport/architect/2026-09-06_alias-menu-fix-design.md (this file) only.
```
@@ -0,0 +1,49 @@
# Builder Report — Alias Menu Fix (2026-09-06)
## TL;DR
- **Status:** IMPLEMENTED
- **Files changed:** `lib/menu-lib.sh`, `bin/pos-ai-alias`, `AgentsReport/builder/2026-09-06_alias-menu-fix.md`
- **Verification:** bash -n OK; make gen ×2 (deterministic, no drift); make check OK; make lint 0 FAIL 0 WARN; functional smoke 7/7 pass.
- **Summary:** Added `--allow-empty` flag to `menu_ask_value`, switched 2 alias call sites (353, 410), fixed step counters 352/383 → /5.
---
## Step 1: Update function index line in lib/menu-lib.sh
Updated line 27: `menu_ask_value <label> [default]``menu_ask_value [--allow-empty] <label> [default]`.
[DONE]
## Step 2: Add --allow-empty documentation to menu_ask_value doc block
Added doc line (after line 349) documenting the flag: empty+no-default → rc 0 + empty value; only cancel/EOF returns rc 1.
[DONE]
## Step 3: Implement --allow-empty flag in menu_ask_value body
Implemented per architect reference (`--allow-empty` parsed as first arg, shift; empty branch: default→echo+rc 0; no default → allow_empty rc 0 echo empty / else rc 1; cancel always rc 1). Prompt label display unchanged (default suffix only when non-empty).
[DONE]
## Step 4: Fix step counters and add --allow-empty to pos-ai-alias call sites
- line 352: `step 1 4``step 1 5`
- line 353: added `--allow-empty` flag (kept `|| return 0`)
- line 383: `step 2 4``step 2 5`
- line 410: added `--allow-empty` flag (kept `|| return 0`)
[DONE]
## Step 5: Verification (bash -n, make gen, make check, make lint, functional smoke)
- `bash -n lib/menu-lib.sh` → OK; `bash -n bin/pos-ai-alias` → OK.
- `make gen` run twice → both write OK, no gen drift (git diff after gen shows ONLY the 2 source files, no generated blocks touched) → byte-identical on rerun = deterministic.
- `make check` → check-sync: OK.
- `make lint``0 FAIL, 0 WARN`.
- Functional smoke (non-TTY harness at /tmp/opencode/menu_smoke.sh, sources lib/menu-lib.sh):
- empty-no-default no flag → rc 1 (contract unchanged) PASS
- empty-no-default with flag → rc 0 + empty PASS
- empty-with-default with flag → rc 0 + default (default wins) PASS
- empty-with-default no flag → rc 0 + default PASS
- non-empty with flag → rc 0 + value PASS
- non-empty no flag → rc 0 + value PASS
- EOF (no input) with flag → rc 1 (cancel stays cancel) PASS
- TOTAL: 7 pass, 0 fail.
- Alias-name re-prompt reachability (static): line 353 now returns rc 0 + "" on empty Enter → line 355 `[ -z "$name" ]` fires → `warn "Alias name cannot be empty"``continue` → re-prompt (step 1 5 shown again); genuine cancel → rc 1 → `|| return 0` → abort to menu. Verified reachable by design; TTY-level end-to-end create-flow run is a Tester/manual check per Architect's note.
[DONE]
## Handoff
Status: IMPLEMENTED (see final message)
[DONE]
@@ -0,0 +1,347 @@
# 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 0`**silent 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
`read``menu_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" ] || return 0`. Empty means "back out". Correct. |
| 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 --short`**clean** (empty). Working tree matches HEAD `0b5043a`.
- `git diff HEAD -- bin/pos-ai-alias lib/menu-lib.sh`**empty**. 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).
```
@@ -0,0 +1,90 @@
# Tester Report — Alias-Menu Fix: Regression Tests (2026-09-06)
## TL;DR
- **Status:** IN PROGRESS — see per-step markers below; final summary lands in Step 5/6.
- **Scope:** permanent regression coverage for Builder's alias-menu fix (lib/menu-lib.sh `--allow-empty` + bin/pos-ai-alias 353/410 + step counters 352/383 → /5). Tests only; no source/doc edits.
- **Methods used:** (1) unit matrix on the real `menu_ask_value` via the non-TTY stdin path, (2) static source guards via brace-extracted function bodies (same `extract_fn` pattern as t-ai-server-validate.sh), (3) pty E2E through `script -qec` driving the REAL `pos ai alias create` flow.
- **Pty verdict:** FEASIBLE. `script -qec` with paced input (0.3s inter-input sleep) drives the raw-mode reader deterministically. 3 E2E scenarios proven.
- **Files added:** `tests/t-menu-allow-empty.sh`, `tests/t-ai-alias-create-e2e.sh`.
---
## Step 1: Pty feasibility experiment `[DONE]`
Attempted `script -qec` with a piped script of inputs per DEV.md §7 (`printf 'answer\n' | script -qec "cmd" /dev/null`).
**Evidence (probe A — the reported bug scenario):**
```
printf 'testbot\n1\n\n\n\ny\n' (paced 0.4s) | script -qec "$ROOT/bin/pos-ai-alias create" typescript
rc=0
typescript showed: [1/5] Alias Name → [2/5] Provider → [3/5] Session Name →
[4/5] System Prompt (EMPTY Enter) → [5/5] Trust Level → "Alias 'testbot' created."
env file: testbot|gemini|testbot||0 (4th field EMPTY — the fix)
wrapper: ~/.local/bin/testbot exists, exec line without --system
```
**Pacing requirement (probe: no-sleep):** all input at once is UNRELIABLE — `menu_read_value`'s raw reader consumes the whole queued burst via `dd bs=4096`, so bytes after the first submitted newline are discarded and later readers see EOF → clean abort. Input MUST be paced (sleep between inputs). 0.2s and 0.3s pacing both proven reliable for all three scenarios; 0.1s is NOT reliable for the empty-first-input case.
**Rejected alternatives:** `expect` not installed; python3 pty module available but unnecessary — `script` is deterministic with pacing and keeps the suite zero-dependency (bash + util-linux coreutils, matching DEV.md precedent).
**Verdict:** pty driver FEASIBLE → E2E included in the permanent suite (case table below).
---
## Step 2: Unit matrix — menu_ask_value --allow-empty `[PENDING]`
See coverage table (Part A of t-menu-allow-empty.sh) — implementation landed, run results pending full suite.
---
## Step 3: Static source guards `[PENDING]`
Create-flow step labels 1-5 of /5 (extracted `_alias_create` body); exactly 2 `--allow-empty` call sites; edit flow still /4. See coverage table.
---
## Step 4: E2E create flow (pty) `[PENDING]`
See coverage table (3 cases in t-ai-alias-create-e2e.sh).
---
## Step 5: Full suite `[PENDING]`
## Step 6: Gates `[PENDING]`
---
## Coverage table
| case | technique | result |
|------|-----------|--------|
| _— unit matrix —_ | | |
| empty+no-default, no flag → rc 1 | non-TTY stdin, real lib | _pending_ |
| empty+no-default, --allow-empty → rc 0 + empty | non-TTY stdin, real lib | _pending_ |
| empty+default, --allow-empty → rc 0 + default (default wins) | non-TTY stdin, real lib | _pending_ |
| empty+default, no flag → rc 0 + default (regression) | non-TTY stdin, real lib | _pending_ |
| non-empty, --allow-empty → rc 0 + value | non-TTY stdin, real lib | _pending_ |
| non-empty, no flag → rc 0 + value (regression) | non-TTY stdin, real lib | _pending_ |
| EOF/cancel, --allow-empty → rc 1 (cancel stays cancel) | non-TTY stdin, real lib | _pending_ |
| EOF/cancel, no flag → rc 1 (contract) | non-TTY stdin, real lib | _pending_ |
| value beats default with --allow-empty | non-TTY stdin, real lib | _pending_ |
| _— static source guards —_ | | |
| create steps 1..5 all labeled /5 (no /4) | extract_fn(_alias_create) + grep | _pending_ |
| exactly 2 --allow-empty call sites (353/410), both in create | grep bin/pos-ai-alias + extract_fn | _pending_ |
| edit flow still 4 × /4 (untouched) | extract_fn(_alias_edit) + grep | _pending_ |
| no OTHER tool adopted --allow-empty (scope fence) | grep -l bin/pos-* | _pending_ |
| _— pty E2E —_ | | |
| empty System Prompt → trust step reached + alias file created (empty 4th field) | script -qec REAL create flow | _pending_ |
| empty Alias Name → warn + re-prompt [1/5] ×2 → created | script -qec REAL create flow | _pending_ |
| Ctrl-D at System Prompt → clean abort, no [5/5], no alias, no env | script -qec REAL create flow | _pending_ |
| script binary unavailable → documented SKIP | require_cmd | _pending_ |
---
## Constraints respected
- Only tests + this report modified; no lib/ bin/ AGENT_TODO.md docs touched; no `make gen`.
- Hard-skip contract honored (SKIP only via `skip_case`, never a fake pass).
- Runtime budget respected (target ≤ ~70s; suite ~49s today + ~10s E2E).
+4 -4
View File
@@ -349,8 +349,8 @@ _alias_create() {
local name="$preset_name"
while true; do
if [ -z "$name" ]; then
step 1 4 "Alias Name" >&2
name="$(menu_ask_value "Alias name" "")" || return 0
step 1 5 "Alias Name" >&2
name="$(menu_ask_value --allow-empty "Alias name" "")" || return 0
fi
[ -z "$name" ] && { warn "Alias name cannot be empty" >&2; name=""; continue; }
if ! _alias_name_valid "$name"; then
@@ -380,7 +380,7 @@ _alias_create() {
done
# Step 2: Provider
step 2 4 "Provider" >&2
step 2 5 "Provider" >&2
local pidx
pidx="$(_alias_provider_pick)" || return 0
local providers=()
@@ -407,7 +407,7 @@ _alias_create() {
local prompt=""
while true; do
step 4 5 "System Prompt" >&2
prompt="$(menu_ask_value "System prompt (empty = use built-in)" "")" || return 0
prompt="$(menu_ask_value --allow-empty "System prompt (empty = use built-in)" "")" || return 0
if [[ "$prompt" == *'|'* ]]; then
warn "System prompt must not contain '|' characters" >&2
prompt=""; continue
+11 -3
View File
@@ -24,7 +24,7 @@
# menu_guard rc 0 iff stdin is a terminal
# menu_run <title> <item...> numbered menu loop → chosen index
# menu_pick <prompt> <item...> type-to-filter picker → chosen index
# menu_ask_value <label> [default] prompted value → entered text
# menu_ask_value [--allow-empty] <label> [default] prompted value → entered text
# menu_read_value <label> raw-mode bracketed-paste reader
# menu_redraw internal redraw (menu_read_value only)
@@ -347,15 +347,23 @@ menu_redraw() {
# text — including multi-line pastes — inserts it literally instead of letting
# leftover lines escape to the shell as commands.
# rc 0 value on stdout · rc 1 EOF/cancel, or empty answer with no default.
# With --allow-empty: empty answer with no default → rc 0 + empty value;
# only genuine cancel/EOF returns rc 1.
menu_ask_value() {
local allow_empty=0
if [ "${1:-}" = "--allow-empty" ]; then
allow_empty=1
shift
fi
local label="$1" def="${2:-}" val pr="$1"
[ -n "$def" ] && pr="$pr [$def]"
if ! val="$(menu_read_value "$pr")"; then
return 1 # EOF / cancel
fi
if [ -z "$val" ]; then
[ -n "$def" ] || return 1
echo "$def"
[ -n "$def" ] && { echo "$def"; return 0; }
[ "$allow_empty" -eq 1 ] || return 1
echo ""
return 0
fi
echo "$val"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
# t-menu-allow-empty.sh — regression for the alias-menu fix (2026-09-06):
# Part A: menu_ask_value --allow-empty semantics matrix against the REAL
# lib/menu-lib.sh via the non-TTY stdin path (the same deterministic
# route as the Detective's repro — no TTY, no raw-mode reader).
# Default contract (empty + no default → rc 1) must be unchanged;
# the flag must flip ONLY the empty+no-default cell (rc 0 + empty);
# genuine EOF/cancel must remain rc 1 even with the flag.
# Part B: static guards on bin/pos-ai-alias — create-flow steps 1..5 must all
# be labeled /5 (FAILS if anyone regresses /4 back), exactly the 2
# approved --allow-empty call sites must exist (both in _alias_create),
# the edit flow must stay untouched at 4 × /4, and no OTHER tool may
# have adopted the flag (scope fence).
#
# Strategy: production logic is never re-typed. menu_ask_value is sourced from
# lib/menu-lib.sh; function bodies are brace-extracted from bin/pos-ai-alias
# with the same extract_fn pattern as t-ai-server-validate.sh.
# extract_fn <source-file> <fnname> — print one brace-delimited function body.
extract_fn() {
local file="$1" fn="$2"
awk -v fn="$fn" '
BEGIN { found=0; depth=0 }
{
if (!found && $0 ~ ("^" fn "\\(\\)")) { found=1; depth=0 }
if (found) {
n_open = gsub(/\{/, "{")
n_close = gsub(/\}/, "}")
depth = depth + n_open - n_close
print
if (depth <= 0) exit
}
}
' "$file"
}
# ask_case <desc> <feed(printf %b bytes)> <expect_rc> <expect_out> <args...>
# Feeds literal bytes to the REAL menu_ask_value and asserts rc + stdout.
ask_case() {
local desc="$1" feed="$2" expect_rc="$3" expect_out="$4"
shift 4
local out rc
set +e
out="$(printf '%b' "$feed" | menu_ask_value "$@" 2>/dev/null)"
rc=$?
set -e
check_rc "$desc" "$expect_rc" "$rc"
check_eq "$desc (value)" "$expect_out" "$out"
}
run_test() {
local sandbox alias_tool
sandbox="$(mksandbox menu-allow-empty)"
alias_tool="$ROOT/bin/pos-ai-alias"
source "$ROOT/lib/menu-lib.sh"
# ═══ Part A0: reader contract the fix builds on (non-TTY fallback) ═══
# menu_read_value must distinguish empty-Enter (rc 0 + empty) from EOF (rc 1);
# otherwise --allow-empty could never tell cancel from empty.
local rr_out rr_rc
# empty line → rc 0, empty value
set +e
rr_out="$(printf '\n' | menu_read_value "probe" 2>/dev/null)"; rr_rc=$?
set -e
check_rc "reader: empty line → rc 0" 0 "$rr_rc"
check_eq "reader: empty line → empty value" "" "$rr_out"
# EOF → rc 1 (cancel)
set +e
rr_out="$(printf '' | menu_read_value "probe" 2>/dev/null)"; rr_rc=$?
set -e
check_rc "reader: EOF → rc 1 (cancel)" 1 "$rr_rc"
# ═══ Part A: --allow-empty semantics matrix (Builder's 7 smoke cases) ═══
# 1. empty line, no default, WITHOUT flag → rc 1 (default contract unchanged)
ask_case "empty-no-default no flag → rc 1 (contract unchanged)" '\n' 1 "" "label" ""
# 2. empty line, no default, WITH --allow-empty → rc 0 + empty value
ask_case "empty-no-default with flag → rc 0 + empty" '\n' 0 "" "--allow-empty" "label" ""
# 3. empty line, default present, WITH flag → rc 0 + default (default wins)
ask_case "empty-with-default with flag → default wins" '\n' 0 "def" "--allow-empty" "label" "def"
# 4. empty line, default present, no flag → rc 0 + default (regression)
ask_case "empty-with-default no flag → default (regression)" '\n' 0 "def" "label" "def"
# 5. non-empty line WITH flag → rc 0 + value
ask_case "non-empty with flag → rc 0 + value" 'xyz\n' 0 "xyz" "--allow-empty" "label" ""
# 6. non-empty line, no flag → rc 0 + value (regression)
ask_case "non-empty no flag → rc 0 + value (regression)" 'xyz\n' 0 "xyz" "label" ""
# 7. genuine EOF (no input at all) WITH flag → rc 1 (cancel stays cancel)
ask_case "EOF with flag → rc 1 (cancel stays cancel)" '' 1 "" "--allow-empty" "label" ""
# 8. genuine EOF, no flag → rc 1 (contract, flag independent)
ask_case "EOF no flag → rc 1 (contract)" '' 1 "" "label" ""
# 9. typed value beats default with flag (default only covers empty)
ask_case "typed value beats default with flag" 'xyz\n' 0 "xyz" "--allow-empty" "label" "def"
# ═══ Part B: static source guards (create-flow step labels + call sites) ═══
local create_fn="$sandbox/alias-create-fn.sh"
local edit_fn="$sandbox/alias-edit-fn.sh"
extract_fn "$alias_tool" _alias_create > "$create_fn"
extract_fn "$alias_tool" _alias_edit > "$edit_fn"
# Create flow: exactly 5 numbered steps, all labeled /5 → FAILS if /4 returns.
local nums totals
nums="$(grep -oE 'step [0-9]' "$create_fn" | awk '{print $2}' | tr '\n' ' ')"
totals="$(grep -oE 'step [0-9] [0-9]' "$create_fn" | awk '{print $3}' | tr '\n' ' ')"
check_eq "create flow steps numbered 1..5" "1 2 3 4 5 " "$nums"
check_eq "create flow step totals all /5" "5 5 5 5 5 " "$totals"
check_eq "create flow has no /4 label" 0 "$(grep -cE 'step [0-9] 4' "$create_fn" || true)"
# Exactly the 2 approved --allow-empty call sites, both inside _alias_create.
check_eq "pos-ai-alias has exactly 2 --allow-empty call sites" 2 \
"$(grep -c 'menu_ask_value --allow-empty' "$alias_tool" || true)"
check_eq "both --allow-empty sites inside _alias_create" 2 \
"$(grep -c 'menu_ask_value --allow-empty' "$create_fn" || true)"
# Edit flow untouched: still 4 steps, all /4, none with the flag.
check_eq "edit flow has 4 numbered steps" 4 \
"$(grep -cE 'step [0-9] [0-9]' "$edit_fn" || true)"
check_eq "edit flow all /4 (untouched)" 4 \
"$(grep -cE 'step [0-9] 4' "$edit_fn" || true)"
check_eq "no --allow-empty in edit flow" 0 \
"$(grep -c 'menu_ask_value --allow-empty' "$edit_fn" || true)"
# Scope fence: no OTHER pos tool adopted the flag.
check_eq "no other tool adopted --allow-empty (scope fence)" 0 \
"$(grep -l 'menu_ask_value --allow-empty' "$ROOT"/bin/pos-* 2>/dev/null | grep -vc 'pos-ai-alias$' || true)"
}