Skip to content

th-426791: block send while a turn is in flight - #321

Merged
brentrager merged 2 commits into
mainfrom
th-426791-block-send
Aug 2, 2026
Merged

th-426791: block send while a turn is in flight#321
brentrager merged 2 commits into
mainfrom
th-426791-block-send

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

Sending a message while a turn was already running did not queue behind it. The daemon spawned a second concurrent turn; the two streamed back interleaved and each answer landed under the other's prompt. The agentic conversation bench (#315) reproduces it with plainly swapped responses, and it is the root cause behind the contradictory date/calendar replies.

The Stop button (#316, th-3a912a) gave a way out of a bad turn but deliberately left the footgun in place — its own PR body flagged blocking send as the follow-up. This is that follow-up.

Block, not queue

Send is refused while a turn is in flight. Queueing was considered and rejected: a queued message is composed against a conversation state the user never saw (the turn it was waiting on may have changed everything), and firing it silently later is the same "an answer arrived for a prompt I didn't mean" surprise the concurrency bug produced. Stop is the explicit, already-visible alternative, so blocking removes nothing the user can't get back with one click.

Composers covered

smooth-web (crates/smooth-web/web)

  • src/turn-guard.tscanSend({ text, attachments, disabled, turnActive }), a pure predicate with no imports. Enter and the send button both route through it, so there is one place the rule lives (the button already flipped to Stop; Enter was the hole).
  • The draft is kept in the box — nothing is taken or cleared.
  • Affordance: placeholder becomes Big Smooth is working — Stop to interrupt, and once there's a non-empty draft a line under the composer reads Turn in progress — Stop to interrupt, or wait; your draft is kept. It only appears when there's actually a swallowed keystroke to explain, so it isn't noise on every turn.

th code (crates/smooth-code) — it had the same free-send behaviour: Enter spawned another run_agent_streaming regardless of state.thinking.

  • blocks_second_turn(input, turn_in_flight) in app.rs, checked before take_input() so the draft survives.
  • Only InputKind::Normal is blocked. Slash commands and !shell run locally, so /clear, /quit, /help stay usable — th code has no interrupt, and blocking everything would leave the user with no way out at all.
  • The input box titles itself ▶ Working… send paused while thinking.
  • Known ceiling, marked with a ponytail: comment: an unknown /name that resolves to a skill also dispatches a turn and isn't blocked, because deciding that needs the skill registry. Corner of a corner.

Tests

  • crates/smooth-web/web/src/turn-guard.test.ts — 4 tests on canSend via node --test (pnpm test in the web dir; node --experimental-strip-types, no new dependency and no test framework added). Needed allowImportingTsExtensions in tsconfig.json for the explicit .ts specifier.
  • crates/smooth-code/src/app.rssecond_turn_guard_tests, 4 tests: idle passes everything, a chat message is blocked mid-turn, local commands stay live mid-turn, an empty draft isn't claimed.

Verification

  • cargo test -p smooai-smooth-code321 passed.
  • cargo clippy -p smooai-smooth-code --all-targets — clean, zero warnings.
  • cargo fmt -- --check — clean.
  • pnpm test (web) — 4/4 pass.
  • pnpm typecheck + pnpm build (web) — clean.
  • oxfmt applied to the three touched/added web files; oxlint on them reports only the pre-existing no-img-element hits on untouched lines (same three as th-3a912a: Stop button — interrupt a running Big Smooth turn #316).

Not runtime-validated against a live daemon; the guard is pure client-side state with no protocol change.

🤖 Generated with Claude Code

A message sent during a running turn did not queue — the daemon spawned a
SECOND concurrent turn, the two streamed back interleaved, and each answer
landed under the other's prompt. That is the root cause of the swapped
responses the agentic bench reproduces, and of the ical/date mess.

Both composers now refuse the dispatch instead of racing:

- smooth-web: one pure predicate (`canSend`) gates Enter and the send button.
  The draft is kept, the placeholder and a hint line explain the pause, and
  Stop (th-3a912a) stays available as the way through.
- `th code`: Enter checks before `take_input`, so nothing is lost. Local slash
  commands and `!shell` stay live; the input box titles itself "Working… send
  paused" so the swallowed keystroke reads as intent, not breakage.

Blocking rather than queueing: a queued message would be composed against a
conversation state the user never saw, and firing it silently later is the
same surprise the concurrency bug produced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 876b146

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/smooth Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager enabled auto-merge (squash) August 2, 2026 21:26
@brentrager
brentrager merged commit ddc6b39 into main Aug 2, 2026
3 checks passed
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.

1 participant