A model refusal discards the answer and reports success
- Status: Todo
- Kind: Bug
- Authors: jp
- Date: 2026-09-07
- Label: domain=llm
- Label: package=jp_llm
- Label: type=bug
When a provider ends a stream with FinishReason::Refused, JP erases the answer from the conversation record and exits 0. Whatever already reached the terminal stays there, so a run's visible output and its saved record disagree about what the assistant said.
TurnCoordinator::handle_streaming_event (crates/jp_cli/src/cmd/query/turn/coordinator.rs:326-332) clears the event builder and pops the already-flushed responses back out of the conversation stream, per the Refused contract that partial output must not be kept. It does not touch the renderer: Event::Part streams each chunk through self.view as it arrives (coordinator.rs:264-282), and the self.view.flush() at line 339 drains whatever is still buffered. It then transitions to Complete, turn_loop returns Ok(()), and the run ends as RunOutcome::AsExpected. The only statement that anything happened is the chrome line from finish_notice (coordinator.rs:674), e.g. The model declined this request (cyber): declined for safety, which is commentary on a successful run by every mechanical definition JP uses: printed mid-run through the printer, exit status 0, never reaching parse_error.
The question to decide
Is a refusal a failed run?
If it is, the outcome belongs in the error path: return Err, let parse_error render it, exit non-zero. The message then reaches the user through the run's error report, which --quiet deliberately keeps, and no separate channel or exemption is needed for it.
If it is not, then a run whose answer survives only on the terminal needs some other justification. A script reading stdout cannot distinguish it from a model that legitimately had little to say, and a later replay cannot show it at all.
Why it is filed rather than fixed in place
Raised while reviewing PR #1088, which makes --quiet close the chrome channel. That PR made the refusal notice suppressible, which prompted the question, but the defect predates it: the discarded record and the zero exit status are there whatever --quiet does. Changing jp query's exit status is a contract change for every script around it, so it wants its own decision rather than riding along.
Severity
Hidden, in two shapes.
A refusal that streamed nothing before declining is indistinguishable from a short successful answer: empty stdout, exit 0, nothing in the conversation.
A refusal that streamed first leaves a partial answer on the terminal that the saved conversation has no record of, so stdout and a later jp conversation print disagree. The exit status is 0 either way, and under --quiet or with stderr redirected neither shape says why.