fix(chat): read a one-shot prompt from piped stdin - #339
Conversation
`rocm chat --help` documents the `echo "…" | rocm chat` path, but the handler never consumed stdin: the interactive branch required a TTY, so piped input fell through to the non-interactive `None` arm, which rendered the status screen via `render_chat_text` and ignored stdin. Resolve the prompt from `--prompt` or, when it is omitted and stdin is not a terminal, from piped standard input, then route it through the same `render_chat_prompt_text` send path as `--prompt`. An interactive TTY or empty piped input still falls back to the status screen, so the no-argument behavior is unchanged. Add a `read_piped_prompt` helper, a unit test asserting the no-prompt path reads stdin, and a Gherkin scenario (@id:chat-cli-stdin-prompt) that pipes a prompt with no `--prompt` and asserts the assistant reply. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
3647940 to
c592acd
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Piped prompts currently lose meaningful leading and trailing whitespace.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds piped-stdin support for one-shot rocm chat prompts.
Changes:
- Reads non-terminal stdin when
--promptis omitted. - Routes piped prompts through the existing one-shot chat path.
- Adds unit and end-to-end regression coverage.
File summaries
| File | Description |
|---|---|
apps/rocm/src/main.rs |
Implements piped prompt handling and a regression test. |
tests/e2e-cucumber/features/chat.feature |
Adds the stdin prompt scenario. |
tests/e2e-cucumber/tests/e2e/chat_steps.rs |
Implements the piped-input test step. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`read_piped_prompt` returned `buf.trim()`, so a prompt piped into `rocm chat` lost its leading indentation and its trailing spaces or tabs, while the same text passed with `--prompt` reached the model verbatim. Whitespace is content for a model, so the two entry points have to agree. Split the normalization out into `piped_prompt_from_input`, which strips only the line ending the writer appends — a single trailing `\n`, plus the `\r` in front of it on Windows — and preserves everything else, further blank lines included. `trim()` now only classifies the input: whitespace- only stdin still yields `None`, so the status-screen fallback for an empty pipe is unchanged. Cover both halves with unit tests on the new helper, and extend @id:chat-cli-stdin-prompt to pipe an indented, trailing-spaced prompt and assert on the request body the mock recorded. The canned reply never varies with the prompt, so only the recorded request can prove the text arrived unaltered; the scenario fails against the previous `trim()`. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
jussielo-amd
left a comment
There was a problem hiding this comment.
Logic is correct: piped stdin now routes through the same render_chat_prompt_text path as --prompt, and the whitespace-preservation follow-up is well tested (asserts on the recorded request body, not just the canned reply, which actually proves fidelity).
Blocking: windows-build-and-test fails — feature_naming rejects the new scenario:
chat.feature: scenario 9 is named "chat-08 - ..." but must start with "chat-09 - "
Main gained a scenario after this branch diverged (#321), so chat-08 is already taken. Rebase onto main and rename the new scenario to chat-09.
Nit: blank piped stdin silently falls back to the status screen with no indication the prompt was empty. A stderr note (like the existing --provider note a few lines up) would remove the ambiguity.
(Strix Halo Ubuntu / MI300X / WSL2 red checks look like runner-availability flake, not caused by this change.)
main added a chat scenario and renumbered chat.feature, so bring the branch up to date before renumbering the new stdin scenario. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
main added chat-04 and renumbered chat.feature, so its last scenario is now chat-08 — the same index this branch gave the new stdin scenario. On the merge the two collided and feature_naming failed on both uniqueness and per-feature sequence. The scenario is declared last, so chat-09 is its sequential index; the @id: tag is unchanged. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
|
Merged
The scenario is declared last, so it is now Verified on Linux before pushing: |
|
🔴 Automated review · pr-review-watcher · 48f2718 SummaryMakes 🚫 Blocking (must fix before merge)None. Non-blocking
|
…lback comment Follow-ups on the piped-stdin chat prompt, from review of 48f2718. `rocm chat` without `--prompt` now reads stdin to EOF, so any non-interactive caller that leaves an idle pipe on fd 0 waits instead of printing the status screen. `scripts/smoke_local.py` is exactly such a caller: its `run()` inherited the script's own stdin, so the repo's §8 gate was only incidentally safe (a terminal or a CI step with no stdin). Hand every smoke child `/dev/null`, as `scripts/vllm_therock_gpu_test.py` already does. No smoke command reads input. The comment above the prompt resolution claimed the status-screen fallback is reached only when stdin is an interactive TTY. It is not: `interactive_terminal()` also requires stdout to be a TTY, and an empty pipe or `< /dev/null` lands there too. Say both ways it is reached. Also record on `read_piped_prompt` that reading to EOF and surfacing a read error (rather than folding it into "no prompt") are deliberate, and note on `piped_prompt_from_input` that dropping whitespace-only stdin is the one intended divergence from `--prompt`, which forwards `" "` verbatim. `command_chat_reads_prompt_from_piped_stdin` was a source grep that would still pass if the read's result were discarded or moved after the send decision. Pin both, and stop the docstring implying it covers the behavior — `chat-09` (`@id:chat-cli-stdin-prompt`) does that. Signed-off-by: Roman Sirokov <roman.sirokov@amd.com>
|
Thanks — worked the five non-blocking points. All five are addressed in b2a0770 (no behavior change to the fix itself). 1. 2. 3. The "stdin is an interactive TTY here" comment is false — correct, fixed. 4. The source-grep test would pass if the result were discarded — correct, tightened rather than dropped (dropping it would lose the wiring guard). It now also pins that the read supplies the dispatched prompt and that it happens before the send decision, and the docstring says plainly that it is a structural guard whose behavioral counterpart is 5. Minor — first two done, third deferred. The whitespace-only/ Gates on b2a0770 (Linux): |
|
🔴 Automated review · pr-review-watcher · b2a0770 This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer. SummaryMakes 🚫 Blocking (must fix before merge)None. Non-blocking
|
The MI300X E2E failure on
|
| attempt | result |
|---|---|
| 1 | 95 scenarios (89 passed, 6 failed) — 5 xfail, 1 unexpected failure |
| 2 | 95 scenarios (90 passed, 5 failed) — 5 xfail, 0 unexpected failure(s) |
chat-07 and the new chat-09 are both fully green in attempt 2, and every other GPU lane
(MI350P, rad3 R9700, Strix Halo WSL2/Ubuntu/Windows) was green on attempt 1 already.
What actually failed
Not the stdin path. chat-09 - The chat CLI reads a one-shot prompt from stdin passed on
attempt 1 (all five steps, in 8 ms). The failing step was chat-07's precondition
And a model is served in the background (serving_steps.rs:653) — a step this PR does not
touch — and the cause was a GPU memory shortfall inside vLLM:
attempt 1: ValueError: Free memory on device cuda:0 (14.93/191.98 GiB) on startup is less than
desired GPU memory utilization (0.92, 176.63 GiB).
attempt 2: ValueError: ... 3.02 GiB KV cache is needed, which is larger than the available
KV cache memory (0.49 GiB).
Both serve attempts found the card already occupied, while the harness's own drain probe,
taken seconds earlier in ensure_serve_port_free(), had reported
device state: drained (195725 MiB free of 196592 MiB, floor 150000 MiB).
The shape of that is a known hazard in the serial GPU lane, and commands.jsonl from the run
shows it: chat-06 runs rocm serve Qwen/Qwen3.5-0.8B --engine vllm --managed and never
stops it, so chat-07 always has to reclaim the card from its predecessor.
kill_listeners_on_port() does that with fuser -k 11435/tcp, which reaches only the process
holding the socket — vLLM v1 splits APIServer and EngineCore into separate processes
(APIServer pid=41476, EngineCore pid=41719 in the archived log), and it is the EngineCore
that owns the ~177 GiB. So the port can close, amd-smi can read the device as drained, and a
still-live or supervisor-restarted engine can take the card back inside the ~25 s window before
the next engine probes it. That race is pre-existing and lives entirely in the GPU e2e harness;
#251 (EAI-8058) is the adjacent work.
Why this PR cannot reach chat-07
- GPU lanes run
max_concurrent_scenarios(1)(tests/e2e-cucumber/tests/e2e.rs), andchat-09
is appended afterchat-07inchat.feature— it cannot run beside it or shift its start. - The diff to
chat_steps.rsis purely additive (two consts, oneWhen, oneThen). run_rocmspawns viaCommand::output(), which sets the child's stdin toStdio::null();
run_rocm_with_stdinwrites then dropsChildStdin, closing the pipe. No e2e child is ever
handed an idle fd 0, so the newread_to_stringcannot block in this suite.
No code change was made — there is nothing here for this PR to fix.
One thing worth a separate decision
The "an idle pipe blocks rocm chat" concern is real, just unrelated to the failure above. On
Linux, with this branch built:
rocm chat < /dev/null→rc=0immediately, status screenrocm chat < <idle fifo>→ still running at 20 s (timeoutreturns 124)printf 'hi\n' | rocm chat→rc=0, prompt path
That is the documented filter contract (read_piped_prompt's doc comment spells it out, and
scripts/smoke_local.py was given /dev/null for exactly this reason), so it is deliberate
rather than a defect — but whether rocm chat should bound that wait, or treat a silent pipe
as "no prompt", is a product decision I have deliberately not made here.
|
🔴 Automated review · pr-review-watcher · b2a0770 This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer. On the GPU e2e failure — I'm satisfied, and I want to be clear about why, because "I re-ran it and it went green" on its own is the weakest possible argument and usually a way of explaining away a real regression. What makes it convincing here is everything attached to the re-run: the failing step is a precondition of a different, pre-existing scenario that this PR doesn't touch; the new stdin scenario itself passed on the first attempt in 8 ms; the lanes run one scenario at a time, so an appended scenario can't run alongside the failing one or move its start; and the step-definition diff is purely additive. On top of that there's an actual named mechanism — the serving engine splitting into a front end and an engine-core process, with the reclaim killing only the socket holder while the core keeps the allocation, which explains exactly the contradiction of a drain probe reporting a clear device seconds before the next engine finds the card occupied. A mechanism plus an unrelated failing step is a real diagnosis, not a hand-wave. Agreed: not a regression from this PR, and the race belongs to the harness. The idle-pipe behaviour is the more interesting point, and I'm glad you raised it unprompted. Hanging forever on a silent pipe is standard filter semantics and I don't think it's a defect in the change — Whether the command should bound the wait or treat silence as "no prompt" is a genuine product call, and I agree it shouldn't be made inside this PR. Leaving it open is the right choice; just don't let it evaporate — it wants a follow-up of its own. Neither point is blocking as far as I'm concerned. This looks good to me. |
Summary
rocm chat --helpdocuments theecho "…" | rocm chatform and states theprompt is read from stdin when
--promptis omitted, but stdin was neverconsumed. The interactive branch requires a TTY, so piped input fell through to
the non-interactive
Nonearm, which rendered the static status screen viarender_chat_textand ignored stdin entirely. Only--promptever sent arequest.
Fix
Resolve the prompt from
--promptor, when it is omitted and stdin is not aterminal, from piped standard input, then route it through the same
render_chat_prompt_textsend path as--prompt. A newread_piped_prompthelper reads stdin to EOF only when it is not a TTY, returning
Nonefor aninteractive terminal or empty input so the no-argument behavior (status
screen / interactive dash) is unchanged.
Reproduce
Before this change:
The piped prompt was dropped and the status screen printed. After this change
the same command routes the stdin text through the
--promptsend path(identical output to
rocm chat --provider local --prompt "Summarize: the sky is blue.").Tests
command_chat_reads_prompt_from_piped_stdinasserting the no-promptpath reads stdin via
read_piped_prompt.@id:chat-cli-stdin-prompt(tests/e2e-cucumber/features/chat.feature)that pipes a prompt with no
--promptand asserts the assistant reply isproduced. Runs on the no-GPU mock lane.
Verified locally: reproduced before the fix, confirmed fixed after; the new and
existing one-shot chat scenarios pass, chat unit tests pass, and
cargo clippy --workspace --all-targets -- -D warningsis clean.Review follow-ups (b2a0770)
Non-blocking points from the automated review of
48f2718a:scripts/smoke_local.py— itsrun()inherited the script's own stdin, so therocm chat --provider localstep is only safe when the harness happens toleave fd 0 a TTY or empty. Confirmed on Linux that the built binary waits
indefinitely with an idle pipe on fd 0 and exits immediately with
/dev/null,so every smoke child now gets
stdin=subprocess.DEVNULL(matchingscripts/vllm_therock_gpu_test.py).python scripts/smoke_local.pypasses.fallback is only reached on an interactive TTY;
interactive_terminal()alsorequires stdout to be a TTY, and an empty pipe lands there too. Corrected.
read_provider_key_from_user), surfacing a read error rather than treating itas "no prompt", and dropping whitespace-only stdin even though
--prompt " "is forwarded verbatim.
command_chat_reads_prompt_from_piped_stdinnow also pins that the read'sresult supplies the dispatched prompt and happens before the send decision,
and its docstring no longer implies it covers the behavior —
chat-09does.