Skip to content

fix(sessions): store displayText when the user's turn lands, not at turn end - #990

Merged
philmerrell merged 1 commit into
developfrom
fix/display-text-on-interrupted-turns
Sep 6, 2026
Merged

fix(sessions): store displayText when the user's turn lands, not at turn end#990
philmerrell merged 1 commit into
developfrom
fix/display-text-on-interrupted-turns

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Follow-up to #988, found while validating it on dev. Pre-existing, and not caused by that change.

The bug

displayText is the clean copy of what the user typed — what the UI renders whenever the prompt sent to the model was augmented (RAG context, attachment guidance, an MCP App context block, or the <interruption_note> addressed to the model after an interrupted turn).

It was written by one call site in the stream coordinator's success path, as the last thing a turn does. Nothing on the Stop, disconnect, or error paths wrote it. So any turn that didn't reach that line left the raw augmented prompt as the only thing the UI could render:

  • Transiently, for every augmented turn: reload while it's still streaming and the model-directed note is sitting in the user's own chat bubble.
  • Permanently, for any turn that never completes: the D# record is never written, so it's there on every load, forever.

The permanent case lands where it hurts most. A turn only carries an interruption note because the previous turn was interrupted — so notes are disproportionately likely to end up on turns that get interrupted too.

Reproduced on dev on both GPT-5.6 Luna and Claude Haiku 4.5 — not model-specific:

displayText (D#) records: [10, 14]      ← only the turns that completed
user messages:            10, 12, 14, 16, 18

Message 18 renders <interruption_note>… in the user bubble on every load.

The fix

DisplayTextHook writes the record on MessageAddedEvent — the moment the user's message enters history, before the model call — so every later exit path already has it. Verified against the installed SDK: the prompt becomes a role-user message appended via Agent._append_messages (agent.py:12891541).

Writing at request start instead would risk a record keyed to an index the failed turn never filled, which a later message would then inherit; anchoring to the append keeps index and record together.

Two subtleties the hook respects:

The coordinator's original write stays as a backstop for wrappers that carry no hook (voice, tests) and for a failed hook write, gated on wrote_this_turn so the normal path still makes exactly one put.

Tests

  • test_display_text_hook.py (12): writes on append; ignores assistant messages; one-shot across tool results; the synthetic-repair message does not consume the arm; re-arming replaces the previous turn's state and clears wrote_this_turn; storage failure never reaches the turn.
  • test_display_text_write.py (7): the coordinator arms every turn with this turn's text and index, arms to None when the prompt wasn't modified, and runs its backstop only when the hook didn't write (verified to fail without the guard).
  • tests/agents + tests/shared/test_sessions_metadata.py: 1,497 passing.

Known limitation, not addressed here

The message index still comes from initial_message_count. In the rare case where Strands inserts the synthetic tool-result repair message, the user's real index shifts by one and the record would miss — pre-existing behaviour, identical before and after this change, and out of scope here.

🤖 Generated with Claude Code

…urn end

`displayText` is the clean copy of what the user typed, and what the UI
renders whenever the prompt sent to the model was augmented — RAG context,
attachment guidance, an MCP App context block, or the `<interruption_note>`
addressed to the model after an interrupted turn.

It was written by a single call site in the stream coordinator's success
path, as the last thing a turn does. Nothing on the Stop, disconnect, or
error paths wrote it, so any turn that didn't reach that line left the raw
augmented prompt as the only thing the UI could render. Two effects, both
reproduced on dev:

  * transiently, for every augmented turn — reload while it is still
    streaming and the note is in the user's own chat bubble;
  * permanently, for any turn that never completes. Stop it and the `D#`
    record is never written, so the note stays in the bubble on every load.

The permanent case lands where it hurts most: a turn only carries an
interruption note because the PREVIOUS turn was interrupted, so the note is
disproportionately likely to be on a turn that gets interrupted too. Not
model-specific — reproduced on GPT-5.6 Luna and Claude Haiku 4.5.

`DisplayTextHook` writes the record on `MessageAddedEvent` instead — the
moment the user's message enters history, before the model call — so every
later exit path already has it. Writing at request start instead would risk
a record keyed to an index the failed turn never filled, which a later
message would inherit; anchoring to the append keeps index and record
together.

Two subtleties the hook has to respect:

  * Not every role-`user` message is the user. Tool results carry that role,
    and Strands prepends a SYNTHETIC tool-result message ahead of the prompt
    when history ends on a dangling `toolUse` — exactly what an interrupted
    tool turn leaves behind. Content carrying `toolResult`/`toolUse` is
    skipped so the arm is spent on the user's own message.
  * The agent instance is cached across turns (#741/#751), so the hook is
    armed unconditionally at the head of every turn, including to None.

The coordinator's write stays as a backstop for wrappers that carry no hook
(voice, tests) and for a failed hook write, gated on `wrote_this_turn` so the
normal path still makes exactly one put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 15b8cd9 into develop Sep 6, 2026
4 checks passed
@philmerrell
philmerrell deleted the fix/display-text-on-interrupted-turns branch September 6, 2026 22:03
@philmerrell philmerrell mentioned this pull request Sep 6, 2026
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