fix(session): surface a refused turn to the user instead of silence - #135
Merged
Conversation
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 <noreply@anthropic.com>
6 tasks
alpibrupa
added a commit
that referenced
this pull request
Sep 5, 2026
run_turn_(streaming_)with_provider re-derived the ENTIRE conversation history from the trail log and content-compared it against the in-memory cache on every single turn, to catch a failed evs.record_assistant append (finish_turn's own comment: "the cache keeps the message anyway"). That re-derivation is O(total history) per turn, so a session that runs long enough is O(n^2) overall. Reproduced live: a synthetic session doing nothing more than ordinary turns of realistic-length assistant text crashed with a step-limit- exceeded panic in lex-schema's json_value parser by turn 46, purely from re-parsing an ever-growing history on every turn — independent of any one message being unusually large. This is what was actually blocking a real multi-file package build via lex-code's agent loop. Replaces the full re-derivation with evs.event_count: a cheap SELECT COUNT(*), compared against the in-memory cache's length. A failed record_assistant append shows up as a count mismatch exactly as reliably as a full content comparison would, since the trail is append-only. `expected` (the cache plus this turn's input) stands in for the old `derived` once the count agrees, since that is what a full derivation would reconstruct anyway absent a divergence. Trade-off, stated plainly: this no longer catches content that silently changed underneath without changing the row count (e.g. the non-ASCII-collapse scenario #135 was about) — only that nothing has gone missing. Accepted because the check it replaces could crash the whole process outright on a long session, which is worse. #135's on_step-routing fix stays needed regardless, for the failure modes event_count still does catch. session_history itself is unchanged and still used for its full, content-verifying cost paid once at session resumption rather than per turn. Verified: a probe reproducing the crash at turn 46 with the old check now completes 200 turns cleanly with event_count. Depends on alpibrusl/lex-schema#36 (also fixed today), which addressed a compounding quadratic bug in the same code path that could still panic within a single turn on escape-dense content regardless of this fix. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
run_turn_streaming_with_providerbuilds a refusal (log-append failure, undecodable history, or cache/trail divergence) as aTurnResultthe caller has to inspect — buttui/main.lex'srun_once/repldiscard the returnedTurnResultby 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 withnull.lex-schema'sjson_valueparser collapses non-ASCII bytes to?(a documented, deliberate tradeoff over there — see lex-schema#31/feat(permissions): collapse per-mode tool policy into single source of truth + wire permission gate #18, not a new bug), which desynced the trail-derived history from the in-memory cache and refused the turn silently.run_turn_streaming_with_providernow routes every refusal throughon_stepvia a newrefused_turn_streamedhelper, instead of leaving it as inert return data.print_stepintentionally prints nothing forStepDone(the text is normally already on screen from streamingTextChunks during a normal turn),refused_turn_streamedalso emits the reason as aStepDelta(TextChunk(...))first — the one channelprint_stepactually renders — before the terminalStepDone.Fixes #134
Test plan
lex check src/server/session.lex, plus a full repo sweep (lex checkover every trackedsrc/file)lex fmt --check src/lex test(4/4 passing)lex doc-sync --checknullbefore this fix; after, it prints[refused: cached messages diverge from the trail-derived history]StepDonepath🤖 Generated with Claude Code