fix(sessions): store displayText when the user's turn lands, not at turn end - #990
Merged
Merged
Conversation
…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>
Merged
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.
Follow-up to #988, found while validating it on dev. Pre-existing, and not caused by that change.
The bug
displayTextis 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:
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:
Message 18 renders
<interruption_note>…in the user bubble on every load.The fix
DisplayTextHookwrites the record onMessageAddedEvent— 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-usermessage appended viaAgent._append_messages(agent.py:1289→1541).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:
usermessage is the user. Tool results carry that role under Bedrock Converse, and Strands prepends a synthetic tool-result message ahead of the prompt when history ends on a danglingtoolUse— precisely the shape an interrupted tool turn leaves behind, i.e. the case this hook exists for. Content carryingtoolResult/toolUseis skipped so the arm is spent on the user's own message.None— same discipline as theturn_leasestamp beside it.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_turnso 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 clearswrote_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 toNonewhen 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