Skip to content

fix(session): surface a refused turn to the user instead of silence - #135

Merged
alpibrupa merged 1 commit into
mainfrom
surface-refused-turn
Sep 5, 2026
Merged

fix(session): surface a refused turn to the user instead of silence#135
alpibrupa merged 1 commit into
mainfrom
surface-refused-turn

Conversation

@alpibrupa

Copy link
Copy Markdown
Contributor

Summary

  • 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 with null.
  • Found live while dogfooding a new package build: 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, 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_provider now routes every refusal through on_step via a new refused_turn_streamed helper, 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 during a normal turn), refused_turn_streamed also emits the reason as a StepDelta(TextChunk(...)) first — the one channel print_step actually renders — before the terminal StepDone.

Fixes #134

Test plan

  • lex check src/server/session.lex, plus a full repo sweep (lex check over every tracked src/ file)
  • lex fmt --check src/
  • lex test (4/4 passing)
  • lex doc-sync --check
  • Live repro before/after: the em-dash task above printed only null before this fix; after, it prints [refused: cached messages diverge from the trail-derived history]
  • Verified no regression on the normal path: a plain-ASCII one-shot task still streams its answer exactly once, no duplicate output from the StepDone path

🤖 Generated with Claude Code

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>
@alpibrupa
alpibrupa merged commit 6abc947 into main Sep 5, 2026
1 check passed
@alpibrupa
alpibrupa deleted the surface-refused-turn branch September 5, 2026 14:15
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-shot/REPL silently swallows a refused turn — no output, no error, just exit

1 participant