From 7172487f55bd72dd2da0e8583e9a4a99ca0f004c Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Sat, 5 Sep 2026 16:13:51 +0200 Subject: [PATCH] fix(session): surface a refused turn to the user instead of silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_turn_streaming_with_provider builds a refusal (log-append failure, undecodable history, or cache/trail divergence) as a TurnResult the caller has to inspect — but tui/main.lex's run_once/repl discard the returned TurnResult by design, to avoid double-printing a normal turn's already-streamed answer. A refused turn therefore printed nothing at all: no error, no explanation, just the process exiting. Found live: a one-shot task string containing an em-dash triggered exactly this. lex-schema's json_value parser collapses non-ASCII bytes to `?` (a documented tradeoff there, not a new bug), which desynced the trail-derived history from the in-memory cache and refused the turn silently. run_turn_streaming_with_provider now routes every refusal through on_step via refused_turn_streamed, instead of leaving it as inert return data. Because print_step intentionally prints nothing for StepDone (the text is normally already on screen from streaming TextChunks), refused_turn_streamed also emits the reason as a TextChunk first — the one channel print_step actually renders. Fixes #134 Co-Authored-By: Claude Sonnet 5 --- src/server/session.lex | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/server/session.lex b/src/server/session.lex index 52db21a..287ca9a 100644 --- a/src/server/session.lex +++ b/src/server/session.lex @@ -367,12 +367,22 @@ fn run_turn_with_provider(session :: Session, user_input :: Str, provider_tag :: # # The budget is derived the way run_loop_traced derives it internally, since # run_steps_streamed takes it explicitly. +# +# A refused turn must reach `on_step` too, not just the returned TurnResult — +# `run_once`/`repl` (tui/main.lex) print only what `on_step` delivers live and +# discard the returned steps to avoid printing the normal path twice (see +# print_step's own comment), so a refusal that skipped `on_step` was +# completely invisible: no error, no explanation, just silence and the +# process exiting. Found live dogfooding this on a task string containing an +# em-dash — lex-schema's json_value parser collapses non-ASCII bytes to `?` +# (a documented, deliberate tradeoff there), which desynced the trail-derived +# history from the in-memory cache and refused the turn with nothing printed. fn run_turn_streaming_with_provider(session :: Session, user_input :: Str, provider_tag :: Str, on_step :: (d.Step) -> [io] Unit) -> [env, net, llm, io, proc, sql, time, approval, stream] TurnResult { let started := time.now_ms() match evs.record_user(session.log, user_input) { - Err(e) => refused_turn(session, str.concat("session log append failed: ", e)), + Err(e) => refused_turn_streamed(session, str.concat("session log append failed: ", e), on_step), Ok(_) => match evs.session_history(session.log) { - Err(e) => refused_turn(session, str.concat("session history underivable: ", e)), + Err(e) => refused_turn_streamed(session, str.concat("session history underivable: ", e), on_step), Ok(derived) => { let expected := list.concat(session.messages, [msg.user(user_input)]) if evs.history_eq(derived, expected) { @@ -381,13 +391,37 @@ fn run_turn_streaming_with_provider(session :: Session, user_input :: Str, provi let steps := ag.run_steps_streamed(agent, derived, budget, session.log, session.parent, on_step) finish_turn(session, derived, steps, started) } else { - refused_turn(session, "cached messages diverge from the trail-derived history") + refused_turn_streamed(session, "cached messages diverge from the trail-derived history", on_step) } }, }, } } +# Same refusal `refused_turn` already builds, but also pushed through +# `on_step` — the streaming callers' only channel to the user. +# +# A normal turn's answer reaches the terminal as TextChunk deltas WHILE it +# streams; by the time its StepDone arrives the text is already on screen, +# which is why tui/main.lex's print_step deliberately prints nothing for +# StepDone (see its own comment — printing the text again there would +# duplicate it). A refusal has no such preceding stream: it never reaches +# the model at all. So this emits the reason as a TextChunk FIRST — the one +# channel print_step actually renders — and only then the StepDone that +# closes the turn, rather than relying on a StepDone payload nothing prints. +fn refused_turn_streamed(session :: Session, reason :: Str, on_step :: (d.Step) -> [io] Unit) -> [io] TurnResult { + let result := refused_turn(session, reason) + let __echoed := match find_done_msg(result.steps) { + None => (), + Some(m) => match m { + AssistantMsg(text, _) => on_step(StepDelta(TextChunk(text))), + _ => (), + }, + } + let __streamed := list.map(result.steps, on_step) + result +} + # Close a turn: record the assistant reply as a durable event and extend the # cache. A FAILED assistant append is deliberately not patched over — the # cache keeps the message anyway, so the next turn's derivation check finds