Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
86 changes: 86 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,86 @@
# 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? }` 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.
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.

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).
2 changes: 2 additions & 0 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,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 and session transcripts. 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; the interactive TUI and the ACP/export transcript-replay path display 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
129 changes: 129 additions & 0 deletions packages/acp-bridge/src/transcript-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,135 @@ describe('createTranscriptReplayMachine', () => {
]);
});

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

it('prefers displayText over the tag-strip fallback and keeps image parts', () => {
// Without displayText the tag-strip path would also emit the middle
// "expanded extra" text part. displayText must win, and the image
// part must survive (the previous early-return path dropped it).
const projected = updates(
createTranscriptReplayMachine(),
record('user-1', 'user', {
message: {
role: 'user',
parts: [
{
inlineData: {
data: 'abc123',
mimeType: 'image/png',
},
},
{ text: 'my prompt' },
{ text: 'expanded extra' },
{ text: tagged },
],
},
systemPayload: {
displayText: 'my prompt',
},
}),
);

expect(projected).toMatchObject([
{
sessionUpdate: 'user_message_chunk',
content: {
type: 'image',
data: 'abc123',
mimeType: 'image/png',
},
},
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'my prompt' },
},
]);
expect(projected).toHaveLength(2);
});

it('appends displayText after images when the record has no text part to replace', () => {
// Exercises the !replaced fallback: after stripping the trailing tagged
// block, only the image remains, so displayText is appended.
const projected = updates(
createTranscriptReplayMachine(),
record('user-img-only', 'user', {
message: {
role: 'user',
parts: [
{
inlineData: {
data: 'abc',
mimeType: 'image/png',
},
},
{ text: tagged },
],
},
systemPayload: {
displayText: 'my image prompt',
},
}),
);

expect(projected).toMatchObject([
{
sessionUpdate: 'user_message_chunk',
content: {
type: 'image',
data: 'abc',
mimeType: 'image/png',
},
},
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'my image prompt' },
},
]);
expect(projected).toHaveLength(2);
});

it('strips a trailing whole-part tagged block when displayText is absent', () => {
const projected = updates(
createTranscriptReplayMachine(),
record('user-2', 'user', {
message: {
role: 'user',
parts: [{ text: 'my prompt' }, { text: tagged }],
},
}),
);

expect(projected).toMatchObject([
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'my prompt' },
},
]);
expect(projected).toHaveLength(1);
});

it('keeps a sole part that matches the tag shape', () => {
const projected = updates(
createTranscriptReplayMachine(),
record('user-3', 'user', {
message: {
role: 'user',
parts: [{ text: tagged }],
},
}),
);

expect(projected).toMatchObject([
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: tagged },
},
]);
});
});

it('projects ordered message parts with source metadata', () => {
const machine = createTranscriptReplayMachine();
const projected = updates(
Expand Down
95 changes: 95 additions & 0 deletions packages/acp-bridge/src/transcript-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
projectGoalStateToLegacy,
type GoalSnapshotV2,
} from '@qwen-code/qwen-code-core/goalWire';
// Narrow path — the helper is Node-free. Importing the core package barrel
// here would pull the whole Node-bound core graph into the browser
// transcript bundle (sdk-typescript daemon/transcript).
import { stripTrailingUserPromptSubmitContextPart } from '@qwen-code/qwen-code-core/userPromptSubmitContext';

export const MISSING_TRANSCRIPT_TOOL_RESULT_MESSAGE =
'Tool result missing from saved history; the previous run likely ended ' +
Expand Down Expand Up @@ -513,10 +517,101 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
return;
}
if (record.subtype !== 'mid_turn_user_message') return;
} else if (!record.subtype) {
// Plain user records — including UserPromptSubmit-augmented ones —
// prefer the recorded display projection, then strip a trailing
// whole-part tagged hook-context block. Matches resumeHistoryUtils.
// Always go through projectMessageParts so multimodal inlineData
// (images) survives even when displayText replaces the text parts.
const payload = isObjectRecord(record.systemPayload)
? record.systemPayload
: undefined;
const displayText =
payload && typeof payload['displayText'] === 'string'
? payload['displayText']
: undefined;
yield* this.projectMessageParts(
displayText
? this.withUserPromptDisplayText(record, displayText)
: this.withoutTrailingUserPromptSubmitContext(record),
'user',
emit,
meta,
);
return;
}
yield* this.projectMessageParts(record, 'user', emit, meta);
}

/**
* Drops a trailing message part that is entirely a tagged UserPromptSubmit
* context block. Injection always appends after the user's own part(s), so
* a sole matching part is treated as user-authored and kept.
*/
private withoutTrailingUserPromptSubmitContext(
record: TranscriptRecordInput,
): TranscriptRecordInput {
const parts = record.message?.parts;
if (!Array.isArray(parts)) {
return record;
}
const nextParts = stripTrailingUserPromptSubmitContextPart(parts);
if (nextParts === parts) {
return record;
}
return {
...record,
message: {
...record.message,
parts: [...nextParts],
},
};
}

/**
* Rebuilds a plain user record for display: strip trailing tagged hook
* context, then replace every text part with a single `displayText` part at
* the first text position so images keep their relative order.
*/
private withUserPromptDisplayText(
record: TranscriptRecordInput,
displayText: string,
): TranscriptRecordInput {
const stripped = this.withoutTrailingUserPromptSubmitContext(record);
const parts = stripped.message?.parts;
if (!Array.isArray(parts) || parts.length === 0) {
return {
...stripped,
message: {
...stripped.message,
parts: [{ text: displayText }],
},
};
}
let replaced = false;
const nextParts: unknown[] = [];
for (const part of parts) {
if (isObjectRecord(part) && typeof part['text'] === 'string') {
if (!replaced) {
nextParts.push({ text: displayText });
replaced = true;
}
continue;
}
nextParts.push(part);
}
if (!replaced) {
nextParts.push({ text: displayText });
}
return {
...stripped,
message: {
...stripped.message,
parts: nextParts,
},
};
}

private *projectAssistantRecord(
record: TranscriptRecordInput,
emit: (update: SessionUpdate) => TranscriptReplayEmission,
Expand Down
4 changes: 4 additions & 0 deletions packages/acp-bridge/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export default defineConfig({
__dirname,
'../core/src/utils/transcript-records.ts',
),
'@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve(
__dirname,
'../core/src/hooks/user-prompt-submit-context.ts',
),
},
},
test: {
Expand Down
Loading
Loading