docs/design/2026-07-28-user-prompt-submit-context-provenance.md
Issue: https://github.com/QwenLM/qwen-code/issues/7940
UserPromptSubmit hooks can return additionalContext, which the client
appends to the outgoing request as a bare text part. Because
recordUserMessage persists the augmented request, the injected text lands in
the user record's message.parts indistinguishable from user-authored text.
Consequences:
partToString(request)
after injection, polluting the prompt attribute and the recall query.The live TUI is unaffected (it builds its history item from the pre-hook input), which is exactly the asymmetry that made the polluted transcript easy to miss.
Isomorphic to two existing patterns: SessionStart context is injected as a
tagged block into the system instruction, and mid-turn/notification records
separate the model-bound message from a systemPayload.displayText
projection.
client.ts): the sanitized additionalContext is
appended as its own part wrapped in
<qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context>.
getAdditionalContext() escapes </> in hook output, so the wrapper
cannot be closed or forged from inside. User-authored text is never
rewritten or escaped. promptText must be declared before the injection
assignment that captures it into preInjectionPromptText (avoids a TDZ
if the surrounding Goal try/catch is later reshuffled).chatRecordingService.ts): recordUserMessage
accepts an optional UserPromptRecordPayload { displayText? } stored as
systemPayload. message keeps the exact model-bound Content — resume
must replay what the model actually saw — while displayText preserves
the pre-injection user projection. Hook-injected text remains in the
tagged message.parts entry (machine-parseable). The payload is only
written when a hook actually injected context.client.ts): addUserPromptAttributes and
MemoryManager.recall use the pre-injection prompt text when injection
occurred.resumeHistoryUtils projects plain user records through a three-shape
fallback:
systemPayload.displayText;The @-command resume branch still prefers AtCommandRecordPayload.userText
when present; only the absent-userText fallback goes through
extractUserRecordDisplayText, so a trailing tagged part does not override
the @-command display text.
UserPromptSubmit path. The ACP session path
already records the pre-injection prompt text, so it only needed the same
tag wrapping on its model-bound injection (included here). Subagent context
injection (SubagentStart via contextState) needs its own investigation
and is a follow-up.displayText in
follow-ups; until then they see the tagged shape, which is at least
mechanically identifiable.ACP/export/daemon consumers that go through transcript-replay's
projectUserRecord also prefer displayText and strip a trailing tagged
part for subtype-less user records (same three-shape fallback as the TUI
resume path).