Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/design/2026-07-28-user-prompt-submit-context-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# UserPromptSubmit hook context provenance

Issue: https://github.com/QwenLM/qwen-code/issues/7940

## Problem

`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:

- **Resume**: the UI projection concatenates all text parts, so resumed
sessions display hook-injected context as if the user typed it.
- **Offline analysis / downstream consumers**: the JSONL transcript cannot
separate user text from injection; consumers resort to fragile custom
marker-stripping heuristics.
- **Telemetry & auto-memory recall**: both consumed `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.

## Design

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.

### Write path

1. **Tagged injection** (`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).
2. **Display provenance** (`chatRecordingService.ts`): `recordUserMessage`
accepts an optional `UserPromptRecordPayload { displayText?, hookContext? }`
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. The payload is only written
when a hook actually injected context.
3. **Telemetry & recall** (`client.ts`): `addUserPromptAttributes` and
`MemoryManager.recall` use the pre-injection prompt text when injection
occurred.

### Read path (resume projection)

`resumeHistoryUtils` projects plain user records through a three-shape
fallback:

- (a) new records: prefer `systemPayload.displayText`;
- (b) tag-only records (no payload): drop a trailing part that is, in its
entirety, a tagged block — whole-part strict match only, so user prose that
merely contains the tag is never stripped. A sole part matching the tag
shape is also kept (injection always appends after the user's own part(s),
so a single-part record can only be user-authored);
- (c) legacy bare-injected records: unchanged concatenation.

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.

## Scope notes

- Focused on the interactive `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.
- Other transcript consumers (desktop, web UI) can adopt `displayText` in
follow-ups; until then they see the tagged shape, which is at least
mechanically identifiable.
2 changes: 2 additions & 0 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ Sequential UserPromptSubmit hooks can append `additionalContext` to `prompt`; `s
- `reason`: human-readable explanation for the decision
- `hookSpecificOutput.additionalContext`: additional context to append to the prompt (optional)

When sent to the model, injected `additionalContext` is appended as its own message part wrapped in a reserved `<qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context>` tag, so it stays distinguishable from user-authored text in model history, session transcripts, and resumed sessions. Angle brackets in hook output are escaped before wrapping, so hook content cannot close or forge the tag. The session transcript also records the user's original prompt text separately, and the interactive UI displays that original text rather than the injected context.

**Note**: Since UserPromptSubmitOutput extends HookOutput, all standard fields are available but only additionalContext in hookSpecificOutput is specifically defined for this event.

**Example Output**:
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
NotificationType,
persistPermissionOutcome,
createHookOutput,
wrapUserPromptSubmitContext,
generateToolUseId,
MessageBusType,
MessageDisplayDispatcher,
Expand Down Expand Up @@ -2847,10 +2848,15 @@ export class Session implements SessionContext {
return { stopReason: 'end_turn' };
}

// Add additional context from hooks to the request
// Add additional context from hooks to the request, wrapped in
// the reserved tag so it stays distinguishable from
// user-authored text (same shape as the interactive path).
const additionalContext = hookOutput?.getAdditionalContext();
if (additionalContext) {
parts = [...parts, { text: additionalContext }];
parts = [
...parts,
{ text: wrapUserPromptSubmitContext(additionalContext) },
];
}
}

Expand Down
123 changes: 123 additions & 0 deletions packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,129 @@ describe('resumeHistoryUtils', () => {
expect(userItem.text).toBe('post-gap message');
});

describe('UserPromptSubmit hook context provenance', () => {
const tagged =
'<qwen:user-prompt-submit-context>\ninjected hook context\n</qwen:user-prompt-submit-context>';

const buildUserItems = (record: Record<string, unknown>) => {
const conversation = {
messages: [record],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
return buildResumedHistoryItems(session, makeConfig({}), 1_000);
};

it('prefers recorded displayText over the augmented parts', () => {
const items = buildUserItems({
type: 'user',
message: { parts: [{ text: 'my prompt' }, { text: tagged }] },
systemPayload: {
displayText: 'my prompt',
hookContext: 'injected hook context',
},
});
expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
});

it('strips a trailing whole-part tagged block when no displayText is recorded', () => {
const items = buildUserItems({
type: 'user',
message: { parts: [{ text: 'my prompt' }, { text: tagged }] },
});
expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
});

it('keeps user-authored text that merely contains the tag', () => {
const items = buildUserItems({
type: 'user',
message: { parts: [{ text: `quote: ${tagged} end` }] },
});
expect(items).toEqual([
{ id: 1_001, type: 'user', text: `quote: ${tagged} end` },
]);
});

it('keeps a sole part that matches the tag shape (user-authored)', () => {
const items = buildUserItems({
type: 'user',
message: { parts: [{ text: tagged }] },
});
expect(items).toEqual([{ id: 1_001, type: 'user', text: tagged }]);
});

it('falls back to raw concatenation for legacy bare-injected records', () => {
const items = buildUserItems({
type: 'user',
message: {
parts: [{ text: 'my prompt' }, { text: 'bare injected context' }],
},
});
expect(items).toEqual([
{ id: 1_001, type: 'user', text: 'my prompt\nbare injected context' },
]);
});

it('prefers at_command userText even when the paired user record has a trailing tagged part', () => {
const conversation = {
messages: [
{
type: 'system',
subtype: 'at_command',
systemPayload: {
userText: '@file.ts summarize this',
filesRead: ['/tmp/file.ts'],
status: 'success',
},
},
{
type: 'user',
message: {
parts: [{ text: 'expanded model prompt' }, { text: tagged }],
},
},
],
} as unknown as ConversationRecord;
const items = buildResumedHistoryItems(
{ conversation } as ResumedSessionData,
makeConfig({}),
1_000,
);
const userItem = items.find((i) => i.type === 'user') as { text: string };
expect(userItem.text).toBe('@file.ts summarize this');
expect(userItem.text).not.toContain('qwen:user-prompt-submit-context');
});

it('strips a trailing tagged part when at_command userText is absent', () => {
const conversation = {
messages: [
{
type: 'system',
subtype: 'at_command',
systemPayload: {
filesRead: ['/tmp/file.ts'],
status: 'success',
},
},
{
type: 'user',
message: {
parts: [{ text: 'my prompt' }, { text: tagged }],
},
},
],
} as unknown as ConversationRecord;
const items = buildResumedHistoryItems(
{ conversation } as ResumedSessionData,
makeConfig({}),
1_000,
);
const userItem = items.find((i) => i.type === 'user') as { text: string };
expect(userItem.text).toBe('my prompt');
});
});

it('converts conversation into history items with incremental ids', () => {
const conversation = {
messages: [
Expand Down
41 changes: 36 additions & 5 deletions packages/cli/src/ui/utils/resumeHistoryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import type {
AtCommandRecordPayload,
HistoryGap,
} from '@qwen-code/qwen-code-core';
import { getToolResponseDisplayText } from '@qwen-code/qwen-code-core';
import {
getToolResponseDisplayText,
isUserPromptSubmitContextPartText,
} from '@qwen-code/qwen-code-core';
import type {
HistoryItem,
HistoryItemInfo,
Expand All @@ -31,6 +34,36 @@ import {
indexGapsByChild,
} from './history-gap-notice.js';

/**
* Projects a plain user record to its display text.
*
* Prefers the `displayText` recorded when a UserPromptSubmit hook augmented
* the model-bound parts. For records that carry the reserved tag but no
* payload (written by other/newer writers), drops a trailing part that is
* entirely a tagged hook-context block. Legacy records with bare injected
* text fall back to the raw part concatenation.
*/
function extractUserRecordDisplayText(
record: ConversationRecord['messages'][number],
): string {
const payload = record.systemPayload as { displayText?: string } | undefined;
if (payload?.displayText) {
return payload.displayText;
}
const parts = (record.message?.parts as Part[] | undefined) ?? [];
const lastText = parts.at(-1)?.text;
// Injection always appends the tagged block after the user's own part(s),
// so a genuine hook-context part is never the sole part. A single-part
// record matching the tag shape is user-authored and must be kept.
const displayParts =
parts.length > 1 &&
typeof lastText === 'string' &&
isUserPromptSubmitContextPartText(lastText)
? parts.slice(0, -1)
: parts;
return extractTextFromParts(displayParts);
}

/**
* Extracts text content from a Content object's parts (excluding thought parts).
*/
Expand Down Expand Up @@ -356,9 +389,7 @@ function convertToHistoryItems(
}

const payload = pendingAtCommands.shift()!;
const text =
payload.userText ||
extractTextFromParts(record.message?.parts as Part[]);
const text = payload.userText || extractUserRecordDisplayText(record);
if (text) {
items.push({ type: 'user', text });
}
Expand All @@ -381,7 +412,7 @@ function convertToHistoryItems(
currentToolGroup = [];
}

const text = extractTextFromParts(record.message?.parts as Part[]);
const text = extractUserRecordDisplayText(record);
if (text) {
items.push({ type: 'user', text });
}
Expand Down
Loading
Loading