diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md
index c2010e0e059..4dd91d909b3 100644
--- a/docs/users/features/hooks.md
+++ b/docs/users/features/hooks.md
@@ -634,6 +634,56 @@ When both fields are present, prompt-hook payloads contain overlapping text and
Sequential UserPromptSubmit hooks can append `additionalContext` to `prompt`; `submitted_prompt` continues to represent the captured submission. Function hooks are trusted same-process code and are not constrained by an immutability guarantee.
+When the final hook output contains non-empty `additionalContext`, Qwen first
+sanitizes the value and then sends it to the model as a separate text part:
+
+```xml
+
+sanitized hook context
+
+```
+
+The tag tells the model and transcript consumers that the part came from a
+configured hook rather than from the user prompt. It is a provenance marker,
+not authentication, authorization, or a general trust boundary.
+
+For a `UserQuery` with this added context, the session JSONL record preserves
+the model-bound parts, including the tagged part, and adds the following
+`systemPayload`:
+
+```json
+{
+ "displayText": "pre-hook display projection",
+ "hookContext": "sanitized hook context"
+}
+```
+
+This two-field payload is written only for this kind of user-prompt record.
+`hookContext` intentionally duplicates the tagged part so offline and
+third-party consumers can identify its provenance without parsing model text.
+`displayText` is the pre-hook display projection and never includes the hook
+context. For a supported interactive TUI submission it is the raw composer
+projection carried by `submitted_prompt`; ACP, headless, `serve`, SDK, remote
+input, and other paths without that provenance record the expanded pre-hook
+prompt instead.
+
+Transcript display consumers treat `displayText` as this user-prompt projection
+when `systemPayload.hookContext` is a string. For compatibility with released
+`displayText`-only user-prompt records, a complete tagged context in the final
+part after at least one other part is equivalent pairing evidence. Notification,
+cron, and mid-turn records can also have `displayText`, but those values are
+compact display labels and must not be substituted for their model-bound text
+without that evidence.
+Legacy bare-context records keep their model-bound display behavior because the
+context cannot be separated reliably. For metadata-free records that use the
+current tagged shape, compatibility consumers may remove the same complete
+final tagged part; they must not infer that arbitrary tag-like user text is hook
+provenance.
+
+Sensitive prompt telemetry attributes, when enabled, and managed auto-memory
+recall both use the pre-hook prompt. They do not include
+`UserPromptSubmit`-added context.
+
**Output Options**:
- `decision`: "allow", "deny", "block", or "ask"
diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts
index efbc9f93d41..7236a01766e 100644
--- a/packages/acp-bridge/src/transcript-replay.test.ts
+++ b/packages/acp-bridge/src/transcript-replay.test.ts
@@ -193,6 +193,124 @@ describe('createTranscriptReplayMachine', () => {
]);
});
+ it('preserves cron display text and source metadata during replay', () => {
+ const projected = updates(
+ createTranscriptReplayMachine(),
+ record('cron-1', 'user', {
+ subtype: 'cron',
+ message: {
+ role: 'user',
+ parts: [{ text: 'cron model text' }],
+ },
+ systemPayload: { displayText: 'Cron job fired' },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: { type: 'text', text: 'Cron job fired' },
+ _meta: {
+ source: 'cron',
+ qwenTranscript: { sourceRecordIds: ['cron-1'] },
+ },
+ },
+ ]);
+ });
+
+ it('uses clean user display metadata while preserving image parts', () => {
+ const machine = createTranscriptReplayMachine();
+ const projected = updates(
+ machine,
+ record('user-1', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ {
+ inlineData: {
+ data: 'image-data',
+ mimeType: 'image/png',
+ },
+ },
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: {
+ type: 'image',
+ data: 'image-data',
+ mimeType: 'image/png',
+ },
+ },
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: { type: 'text', text: 'raw @file prompt' },
+ },
+ ]);
+ });
+
+ it('strips only a complete final tag-only context part', () => {
+ const projected = updates(
+ createTranscriptReplayMachine(),
+ record('user-1', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'user prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ }),
+ );
+
+ expect(projected).toHaveLength(1);
+ expect(projected[0]).toMatchObject({
+ content: { type: 'text', text: 'user prompt' },
+ });
+ });
+
+ it('preserves legacy bare hook context without a reliable boundary', () => {
+ const projected = updates(
+ createTranscriptReplayMachine(),
+ record('user-1', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'user prompt' },
+ { text: 'legacy bare hook context' },
+ ],
+ },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ { content: { type: 'text', text: 'user prompt' } },
+ { content: { type: 'text', text: 'legacy bare hook context' } },
+ ]);
+ });
+
it('preserves Live dialogue boundaries and source during replay', () => {
const machine = createTranscriptReplayMachine();
const projected = updates(
@@ -223,10 +341,9 @@ describe('createTranscriptReplayMachine', () => {
const tagged =
'\ninjected hook context\n';
- 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).
+ it('replaces text parts with displayText while preserving image parts', () => {
+ // displayText must replace all model-facing text while the image part
+ // survives (the previous early-return path dropped it).
const projected = updates(
createTranscriptReplayMachine(),
record('user-1', 'user', {
@@ -246,6 +363,7 @@ describe('createTranscriptReplayMachine', () => {
},
systemPayload: {
displayText: 'my prompt',
+ hookContext: 'injected hook context',
},
}),
);
@@ -267,9 +385,8 @@ describe('createTranscriptReplayMachine', () => {
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.
+ it('appends displayText after an image-only record', () => {
+ // With no text part to replace, displayText is appended after the image.
const projected = updates(
createTranscriptReplayMachine(),
record('user-img-only', 'user', {
@@ -282,11 +399,11 @@ describe('createTranscriptReplayMachine', () => {
mimeType: 'image/png',
},
},
- { text: tagged },
],
},
systemPayload: {
displayText: 'my image prompt',
+ hookContext: 'injected hook context',
},
}),
);
@@ -308,6 +425,43 @@ describe('createTranscriptReplayMachine', () => {
expect(projected).toHaveLength(2);
});
+ it('does not append empty displayText after an image-only record', () => {
+ const onDiagnostic = vi.fn();
+ const projected = updates(
+ createTranscriptReplayMachine({ onDiagnostic }),
+ record('user-img-only-empty-display', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ {
+ inlineData: {
+ data: 'abc',
+ mimeType: 'image/png',
+ },
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: '',
+ hookContext: 'injected hook context',
+ },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: {
+ type: 'image',
+ data: 'abc',
+ mimeType: 'image/png',
+ },
+ },
+ ]);
+ expect(projected).toHaveLength(1);
+ expect(onDiagnostic).not.toHaveBeenCalled();
+ });
+
it('strips a trailing whole-part tagged block when displayText is absent', () => {
const projected = updates(
createTranscriptReplayMachine(),
@@ -328,6 +482,128 @@ describe('createTranscriptReplayMachine', () => {
expect(projected).toHaveLength(1);
});
+ it('uses released single-field displayText when the final tag proves provenance', () => {
+ const projected = updates(
+ createTranscriptReplayMachine(),
+ record('user-single-field-display', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ {
+ inlineData: {
+ data: 'abc123',
+ mimeType: 'image/png',
+ },
+ },
+ { text: 'model-bound prompt' },
+ { text: 'legacy bare hook context' },
+ { text: tagged },
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: {
+ type: 'image',
+ data: 'abc123',
+ mimeType: 'image/png',
+ },
+ },
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: { type: 'text', text: 'raw @file prompt' },
+ },
+ ]);
+ expect(projected).toHaveLength(2);
+ });
+
+ it('does not trust bare displayText on plain user records', () => {
+ const projected = updates(
+ createTranscriptReplayMachine(),
+ record('user-bare-display', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ {
+ inlineData: {
+ data: 'abc123',
+ mimeType: 'image/png',
+ },
+ },
+ { text: 'model-bound prompt' },
+ { text: 'legacy bare hook context' },
+ ],
+ },
+ systemPayload: {
+ displayText: 'notification-style label',
+ },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: {
+ type: 'image',
+ data: 'abc123',
+ mimeType: 'image/png',
+ },
+ },
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: { type: 'text', text: 'model-bound prompt' },
+ },
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: { type: 'text', text: 'legacy bare hook context' },
+ },
+ ]);
+ expect(projected).toHaveLength(3);
+ });
+
+ it('treats paired empty displayText as authoritative', () => {
+ const projected = updates(
+ createTranscriptReplayMachine(),
+ record('user-empty-display', 'user', {
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'expanded model prompt' },
+ {
+ inlineData: {
+ data: 'abc123',
+ mimeType: 'image/png',
+ },
+ },
+ { text: tagged },
+ ],
+ },
+ systemPayload: {
+ displayText: '',
+ hookContext: 'injected hook context',
+ },
+ }),
+ );
+
+ expect(projected).toMatchObject([
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: {
+ type: 'image',
+ data: 'abc123',
+ mimeType: 'image/png',
+ },
+ },
+ ]);
+ expect(projected).toHaveLength(1);
+ });
+
it('keeps a sole part that matches the tag shape', () => {
const projected = updates(
createTranscriptReplayMachine(),
diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts
index 875675cabe0..196f4f95265 100644
--- a/packages/acp-bridge/src/transcript-replay.ts
+++ b/packages/acp-bridge/src/transcript-replay.ts
@@ -10,10 +10,13 @@ import type {
ToolCallLocation,
ToolKind,
} from '@agentclientprotocol/sdk';
-import type {
- TranscriptProjectionDiagnostic,
- TranscriptRecordInput,
- TranscriptReplayGapInput,
+// Use the Node-free transcriptRecords subpath so the browser replay bundle
+// does not pull in the full core package barrel.
+import {
+ projectUserTranscriptForDisplay,
+ type TranscriptProjectionDiagnostic,
+ type TranscriptRecordInput,
+ type TranscriptReplayGapInput,
} from '@qwen-code/qwen-code-core/transcriptRecords';
import {
parseGoalSnapshotV2,
@@ -21,10 +24,6 @@ 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 ' +
@@ -168,6 +167,28 @@ function isObjectRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+function replaceTextPartsForDisplay(
+ parts: readonly unknown[] | undefined,
+ displayText: string,
+): readonly unknown[] {
+ const projected: unknown[] = [];
+ let replacedText = false;
+ for (const part of parts ?? []) {
+ if (isObjectRecord(part) && typeof part['text'] === 'string') {
+ if (!replacedText && displayText.length > 0) {
+ projected.push({ text: displayText });
+ }
+ replacedText = true;
+ } else {
+ projected.push(part);
+ }
+ }
+ if (!replacedText && displayText.length > 0) {
+ projected.push({ text: displayText });
+ }
+ return projected;
+}
+
export function toTranscriptEpochMs(
timestamp?: string | number,
): number | undefined {
@@ -513,25 +534,25 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
emit: (update: SessionUpdate) => TranscriptReplayEmission,
meta: UpdateMetaOptions,
): Iterable {
+ const payload = isObjectRecord(record.systemPayload)
+ ? record.systemPayload
+ : undefined;
if (
record.subtype === 'goal_runtime' ||
record.subtype === 'notification' ||
record.subtype === 'cron' ||
record.subtype === 'mid_turn_user_message'
) {
- const payload = isObjectRecord(record.systemPayload)
- ? record.systemPayload
- : undefined;
const displayText =
payload && typeof payload['displayText'] === 'string'
? payload['displayText']
: undefined;
- const backgroundTask =
- payload && isObjectRecord(payload['backgroundTask'])
- ? payload['backgroundTask']
- : undefined;
if (displayText) {
const isNotification = record.subtype === 'notification';
+ const backgroundTask =
+ payload && isObjectRecord(payload['backgroundTask'])
+ ? payload['backgroundTask']
+ : undefined;
yield emit(
createTranscriptMessageUpdate({
role: 'user',
@@ -553,99 +574,32 @@ 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;
+ }
+
+ const projection = projectUserTranscriptForDisplay(record);
+ if (projection.displayText !== undefined) {
yield* this.projectMessageParts(
- displayText
- ? this.withUserPromptDisplayText(record, displayText)
- : this.withoutTrailingUserPromptSubmitContext(record),
+ record,
'user',
emit,
meta,
+ undefined,
+ replaceTextPartsForDisplay(
+ record.message?.parts,
+ projection.displayText,
+ ),
);
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,
- },
- };
+ yield* this.projectMessageParts(
+ record,
+ 'user',
+ emit,
+ meta,
+ undefined,
+ projection.parts,
+ );
}
private *projectAssistantRecord(
@@ -680,8 +634,9 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
emit: (update: SessionUpdate) => TranscriptReplayEmission,
meta: UpdateMetaOptions,
beforeToolCall?: () => SessionUpdate | undefined,
+ partsOverride?: readonly unknown[],
): Iterable {
- const parts = record.message?.parts;
+ const parts = partsOverride ?? record.message?.parts;
if (!parts) return;
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
const part = parts[partIndex];
diff --git a/packages/cli/src/services/insight/generators/DataProcessor.test.ts b/packages/cli/src/services/insight/generators/DataProcessor.test.ts
index 2b25456d754..5e4b8f63fea 100644
--- a/packages/cli/src/services/insight/generators/DataProcessor.test.ts
+++ b/packages/cli/src/services/insight/generators/DataProcessor.test.ts
@@ -112,6 +112,108 @@ describe('DataProcessor', () => {
expect(result).toContain('[User]: Hello, world!');
});
+ it('should analyze clean user display text instead of hook context', () => {
+ const records: ChatRecord[] = [
+ {
+ sessionId: 'test-session',
+ timestamp: new Date().toISOString(),
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ uuid: '',
+ parentUuid: null,
+ cwd: '',
+ version: '',
+ },
+ ];
+ const result = (
+ dataProcessor as unknown as {
+ formatRecordsForAnalysis(records: ChatRecord[]): string;
+ }
+ ).formatRecordsForAnalysis(records);
+
+ expect(result).toContain('[User]: raw @file prompt');
+ expect(result).not.toContain('hook-only context');
+ });
+
+ it('should keep notification model text instead of its display label', () => {
+ const records: ChatRecord[] = [
+ {
+ sessionId: 'test-session',
+ timestamp: new Date().toISOString(),
+ type: 'user',
+ subtype: 'notification',
+ message: {
+ role: 'user',
+ parts: [{ text: 'notification model text' }],
+ },
+ systemPayload: { displayText: 'Background agent completed' },
+ uuid: '',
+ parentUuid: null,
+ cwd: '',
+ version: '',
+ },
+ ];
+ const result = (
+ dataProcessor as unknown as {
+ formatRecordsForAnalysis(records: ChatRecord[]): string;
+ }
+ ).formatRecordsForAnalysis(records);
+
+ expect(result).toContain('[User]: notification model text');
+ expect(result).not.toContain('Background agent completed');
+ });
+
+ it('should strip a complete final tag-only context part without metadata', () => {
+ const records: ChatRecord[] = [
+ {
+ sessionId: 'test-session',
+ timestamp: new Date().toISOString(),
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'user prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ uuid: '',
+ parentUuid: null,
+ cwd: '',
+ version: '',
+ },
+ ];
+ const result = (
+ dataProcessor as unknown as {
+ formatRecordsForAnalysis(records: ChatRecord[]): string;
+ }
+ ).formatRecordsForAnalysis(records);
+
+ expect(result).toContain('[User]: user prompt');
+ expect(result).not.toContain('hook-only context');
+ });
+
it('should format assistant text messages correctly', () => {
const records: ChatRecord[] = [
{
diff --git a/packages/cli/src/services/insight/generators/DataProcessor.ts b/packages/cli/src/services/insight/generators/DataProcessor.ts
index 628a34b7f07..2aa786c53a8 100644
--- a/packages/cli/src/services/insight/generators/DataProcessor.ts
+++ b/packages/cli/src/services/insight/generators/DataProcessor.ts
@@ -32,6 +32,7 @@ import type {
import {
getInsightPrompt,
runSideQuery,
+ projectUserTranscriptForDisplay,
type Config,
type ChatRecord,
} from '@qwen-code/qwen-code-core';
@@ -217,10 +218,19 @@ export class DataProcessor {
for (const record of records) {
if (record.type === 'user') {
+ const projection = projectUserTranscriptForDisplay(record);
const text =
- record.message?.parts
- ?.map((p) => ('text' in p ? p.text : ''))
- .join('') || '';
+ projection.displayText ??
+ projection.parts
+ .map((part) =>
+ typeof part === 'object' &&
+ part !== null &&
+ 'text' in part &&
+ typeof part.text === 'string'
+ ? part.text
+ : '',
+ )
+ .join('');
output += `[User]: ${text}\n`;
} else if (record.type === 'assistant') {
if (record.message?.parts) {
diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
index 90557deb34c..ce5887c6cb9 100644
--- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
+++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
@@ -232,6 +232,7 @@ describe('resumeHistoryUtils', () => {
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' }]);
@@ -251,6 +252,7 @@ describe('resumeHistoryUtils', () => {
},
systemPayload: {
displayText: 'my prompt',
+ hookContext: 'injected hook context',
},
});
expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
@@ -467,6 +469,171 @@ describe('resumeHistoryUtils', () => {
});
});
+ it('restores ordinary user messages from clean display text', () => {
+ const conversation = {
+ messages: [
+ {
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'expanded model prompt' } as Part,
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ } as Part,
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ },
+ ],
+ } as unknown as ConversationRecord;
+
+ const session: ResumedSessionData = {
+ conversation,
+ } as ResumedSessionData;
+
+ const items = buildResumedHistoryItems(session, makeConfig({}), 30);
+
+ expect(items).toEqual([{ id: 31, type: 'user', text: 'raw @file prompt' }]);
+ });
+
+ it('projects the user turn when legacy @-command metadata has no userText', () => {
+ const conversation = {
+ messages: [
+ {
+ type: 'system',
+ subtype: 'at_command',
+ systemPayload: { filesRead: [], status: 'success' },
+ },
+ {
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'expanded model prompt' } as Part,
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ } as Part,
+ ],
+ },
+ },
+ ],
+ } as unknown as ConversationRecord;
+
+ const items = buildResumedHistoryItems(
+ { conversation } as ResumedSessionData,
+ makeConfig({}),
+ 30,
+ );
+
+ expect(items.find((item) => item.type === 'user')).toMatchObject({
+ text: 'expanded model prompt',
+ });
+ expect(JSON.stringify(items)).not.toContain('hook-only context');
+ });
+
+ it('strips a complete final hook-context part without metadata', () => {
+ const conversation = {
+ messages: [
+ {
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'user prompt' } as Part,
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ } as Part,
+ ],
+ },
+ },
+ ],
+ } as unknown as ConversationRecord;
+
+ const items = buildResumedHistoryItems(
+ { conversation } as ResumedSessionData,
+ makeConfig({}),
+ 30,
+ );
+
+ expect(items).toEqual([{ id: 31, type: 'user', text: 'user prompt' }]);
+ });
+
+ it('keeps legacy bare hook context when no reliable boundary exists', () => {
+ const conversation = {
+ messages: [
+ {
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'user prompt' } as Part,
+ { text: 'legacy bare hook context' } as Part,
+ ],
+ },
+ },
+ ],
+ } as unknown as ConversationRecord;
+
+ const items = buildResumedHistoryItems(
+ { conversation } as ResumedSessionData,
+ makeConfig({}),
+ 30,
+ );
+
+ expect(items).toEqual([
+ {
+ id: 31,
+ type: 'user',
+ text: 'user prompt\nlegacy bare hook context',
+ },
+ ]);
+ });
+
+ it('does not fall back to model-facing text for empty display metadata', () => {
+ const conversation = {
+ messages: [
+ {
+ type: 'user',
+ message: {
+ parts: [
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ } as Part,
+ ],
+ },
+ systemPayload: {
+ displayText: '',
+ hookContext: 'hook-only context',
+ },
+ },
+ ],
+ } as unknown as ConversationRecord;
+
+ const items = buildResumedHistoryItems(
+ { conversation } as ResumedSessionData,
+ makeConfig({}),
+ 30,
+ );
+
+ expect(items).toEqual([]);
+ });
+
it('marks tool results as error, omits thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts
index 6ed5d934d48..d9e9b7ec3cc 100644
--- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts
+++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts
@@ -15,12 +15,11 @@ import type {
SlashCommandRecordPayload,
AtCommandRecordPayload,
HistoryGap,
- UserPromptRecordPayload,
} from '@qwen-code/qwen-code-core';
import {
getToolResponseDisplayText,
parseGoalStateRecordPayloadV2,
- stripTrailingUserPromptSubmitContextPart,
+ projectUserTranscriptForDisplay,
} from '@qwen-code/qwen-code-core';
import type {
HistoryItem,
@@ -42,32 +41,10 @@ import {
extractInlineContentRuns,
} from './inline-image-parts.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 UserPromptRecordPayload | undefined;
- if (payload?.displayText) {
- return payload.displayText;
- }
- const parts = (record.message?.parts as Part[] | undefined) ?? [];
- return extractTextFromParts([
- ...stripTrailingUserPromptSubmitContextPart(parts),
- ]);
-}
-
/**
* Extracts text content from a Content object's parts (excluding thought parts).
*/
-function extractTextFromParts(parts: Part[] | undefined): string {
+function extractTextFromParts(parts: readonly Part[] | undefined): string {
if (!parts) return '';
const textParts: string[] = [];
@@ -407,7 +384,10 @@ function convertToHistoryItems(
}
const payload = pendingAtCommands.shift()!;
- const text = payload.userText || extractUserRecordDisplayText(record);
+ const projection = projectUserTranscriptForDisplay(record);
+ const text =
+ payload.userText ||
+ (projection.displayText ?? extractTextFromParts(projection.parts));
if (text) {
items.push({ type: 'user', text });
}
@@ -430,7 +410,9 @@ function convertToHistoryItems(
currentToolGroup = [];
}
- const text = extractUserRecordDisplayText(record);
+ const projection = projectUserTranscriptForDisplay(record);
+ const text =
+ projection.displayText ?? extractTextFromParts(projection.parts);
if (text) {
items.push({ type: 'user', text });
}
diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap
index 182a381cb1d..f1ecbac8aa6 100644
--- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap
+++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap
@@ -5,6 +5,7 @@ exports[`Core System Prompt (prompts.ts) > should append userMemory with separat
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -204,6 +205,7 @@ exports[`Core System Prompt (prompts.ts) > should include git instructions when
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -419,6 +421,7 @@ exports[`Core System Prompt (prompts.ts) > should include non-sandbox instructio
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -613,6 +616,7 @@ exports[`Core System Prompt (prompts.ts) > should include sandbox-specific instr
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -807,6 +811,7 @@ exports[`Core System Prompt (prompts.ts) > should include seatbelt-specific inst
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -1001,6 +1006,7 @@ exports[`Core System Prompt (prompts.ts) > should not include git instructions w
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -1195,6 +1201,7 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when no
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -1389,6 +1396,7 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -1583,6 +1591,7 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -1777,6 +1786,7 @@ exports[`Model-specific tool call formats > should preserve model-specific forma
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -1996,6 +2006,7 @@ exports[`Model-specific tool call formats > should preserve model-specific forma
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -2285,6 +2296,7 @@ exports[`Model-specific tool call formats > should use JSON format for qwen-vl m
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -2504,6 +2516,7 @@ exports[`Model-specific tool call formats > should use XML format for qwen3-code
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -2789,6 +2802,7 @@ exports[`Model-specific tool call formats > should use bracket format for generi
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -2983,6 +2997,7 @@ exports[`Model-specific tool call formats > should use bracket format when no mo
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
@@ -3177,6 +3192,7 @@ exports[`Model-specific tool call formats > should use native Gemma 4 format for
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index 15b932e940d..c70dbd2e97b 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -321,6 +321,10 @@ const mockUiTelemetryService = vi.hoisted(() => ({
addEvent: vi.fn(),
}));
const mockLogMemoryRecallDelivery = vi.hoisted(() => vi.fn());
+const mockInteractionTelemetry = vi.hoisted(() => ({
+ getActiveInteractionSpan: vi.fn(),
+ addUserPromptAttributes: vi.fn(),
+}));
vi.mock('../telemetry/tracer.js', () => ({
API_CALL_ABORTED_SPAN_STATUS_MESSAGE: 'API call aborted',
API_CALL_FAILED_SPAN_STATUS_MESSAGE: 'API call failed',
@@ -332,6 +336,8 @@ vi.mock('../telemetry/index.js', async (importOriginal) => {
...actual,
uiTelemetryService: mockUiTelemetryService,
logMemoryRecallDelivery: mockLogMemoryRecallDelivery,
+ getActiveInteractionSpan: mockInteractionTelemetry.getActiveInteractionSpan,
+ addUserPromptAttributes: mockInteractionTelemetry.addUserPromptAttributes,
// We keep the real implementations of logChatCompression, etc.
// but we can spy on QwenLogger if needed
};
@@ -582,6 +588,9 @@ describe('Gemini Client (client.ts)', () => {
getSessionTokenLimit: vi.fn().mockReturnValue(32000),
getNoBrowser: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
+ getTelemetryIncludeSensitiveSpanAttributes: vi
+ .fn()
+ .mockReturnValue(false),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
takePendingManualPlanExitNotice: vi.fn().mockReturnValue(undefined),
restorePendingManualPlanExitNotice: vi.fn(),
@@ -9726,6 +9735,81 @@ Other open files:
expect(hookRequest.input).toEqual({ prompt: 'Hi' });
});
+ it('records clean user text separately from tagged hook context', async () => {
+ const recordUserMessage = vi.fn();
+ const interactionSpan = {};
+ const mockMessageBus = {
+ request: vi.fn().mockResolvedValue({
+ output: {
+ hookSpecificOutput: {
+ additionalContext: '',
+ },
+ },
+ }),
+ response: vi.fn(),
+ };
+ vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
+ vi.mocked(mockConfig.getMessageBus).mockReturnValue(
+ mockMessageBus as unknown as ReturnType,
+ );
+ vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
+ (event: string) => event === 'UserPromptSubmit',
+ );
+ vi.mocked(mockConfig.getChatRecordingService).mockReturnValue({
+ recordUserMessage,
+ recordAttributionSnapshot: vi.fn(),
+ recordFileHistorySnapshot: vi.fn(),
+ } as unknown as ReturnType);
+ vi.mocked(
+ mockConfig.getTelemetryIncludeSensitiveSpanAttributes,
+ ).mockReturnValue(true);
+ mockInteractionTelemetry.getActiveInteractionSpan.mockReturnValue(
+ interactionSpan,
+ );
+
+ await fromAsync(
+ client.sendMessageStream(
+ [{ text: 'expanded model prompt' }],
+ new AbortController().signal,
+ 'prompt-hook-display-text',
+ {
+ type: SendMessageType.UserQuery,
+ submittedPrompt: 'raw @file prompt',
+ },
+ ),
+ );
+
+ expect(recordUserMessage).toHaveBeenCalledWith(
+ [
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ '<hook-only context>',
+ '',
+ ].join('\n'),
+ },
+ ],
+ undefined,
+ {
+ displayText: 'raw @file prompt',
+ hookContext: '<hook-only context>',
+ },
+ );
+ expect(mockMemoryManager.recall).toHaveBeenCalledWith(
+ '/test/project/root',
+ 'expanded model prompt',
+ expect.any(Object),
+ );
+ expect(
+ mockInteractionTelemetry.addUserPromptAttributes,
+ ).toHaveBeenCalledWith(
+ mockConfig,
+ interactionSpan,
+ 'expanded model prompt',
+ );
+ });
+
it('passes a non-empty submitted prompt for UserQuery hooks', async () => {
const mockMessageBus = {
request: vi.fn().mockResolvedValue({ output: undefined }),
@@ -9813,7 +9897,10 @@ Other open files:
expect(recordUserMessage).toHaveBeenCalledWith(
[{ text: 'my prompt' }, { text: taggedContext }],
undefined,
- { displayText: 'my prompt' },
+ {
+ displayText: 'my prompt',
+ hookContext: 'extra hook context',
+ },
);
});
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index 53cc1afccfe..d093a54bdba 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -50,7 +50,6 @@ import {
} from '../goals/goalHook.js';
import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js';
import { buildContextUsage } from '../hooks/context-usage.js';
-import { wrapUserPromptSubmitContext } from '../hooks/user-prompt-submit-context.js';
import { DEFAULT_TOKEN_LIMIT, tokenLimit } from './tokenLimits.js';
import { createSessionStartProfiler } from './session-start-profiler.js';
@@ -78,6 +77,7 @@ import {
// Services
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { CommitAttributionService } from '../services/commitAttribution.js';
+import type { UserPromptRecordPayload } from '../services/chatRecordingService.js';
// Tools
import type { RelevantAutoMemoryPromptResult } from '../memory/manager.js';
@@ -143,6 +143,7 @@ import { escapeSystemReminderTags } from '../utils/xml.js';
import { ApiRetryEvent } from '../telemetry/types.js';
import { logApiRetry } from '../telemetry/loggers.js';
import { shouldUsePlanOnlyReminderInSubagentContext } from '../agents/runtime/subagent-plan-tool-policy.js';
+import { wrapUserPromptSubmitContext } from '../utils/transcript-records.js';
// Hook types and utilities
import {
@@ -2279,12 +2280,12 @@ export class GeminiClient {
// content's own pairing.
}
- // Set when the UserPromptSubmit hook injects additional context: the
- // pre-injection prompt projection. Telemetry, memory recall, and chat
- // recording must see the user's own text, not the augmented request.
- let preInjectionPromptText: string | undefined;
-
// Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled)
+ const preHookUserPromptText =
+ messageType === SendMessageType.UserQuery
+ ? partToString(request)
+ : undefined;
+ let userPromptRecordPayload: UserPromptRecordPayload | undefined;
let hooksEnabled: boolean;
let messageBus: ReturnType;
try {
@@ -2305,7 +2306,7 @@ export class GeminiClient {
messageBus &&
this.config.hasHooksForEvent('UserPromptSubmit')
) {
- const promptText = partToString(request);
+ const promptText = preHookUserPromptText ?? partToString(request);
const submittedPrompt =
messageType === SendMessageType.UserQuery &&
typeof options?.submittedPrompt === 'string' &&
@@ -2379,7 +2380,12 @@ export class GeminiClient {
...requestArray,
{ text: wrapUserPromptSubmitContext(additionalContext) },
];
- preInjectionPromptText = promptText;
+ if (messageType === SendMessageType.UserQuery) {
+ userPromptRecordPayload = {
+ displayText: submittedPrompt ?? promptText,
+ hookContext: additionalContext,
+ };
+ }
}
}
} catch (error) {
@@ -2526,7 +2532,7 @@ export class GeminiClient {
addUserPromptAttributes(
this.config,
interactionSpan,
- preInjectionPromptText ?? partToString(request),
+ preHookUserPromptText ?? partToString(request),
);
}
}
@@ -2583,7 +2589,7 @@ export class GeminiClient {
.getMemoryManager()
.recall(
this.config.getProjectRoot(),
- preInjectionPromptText ?? partToString(request),
+ preHookUserPromptText ?? partToString(request),
{
config: this.config,
excludedFilePaths: this.surfacedRelevantAutoMemoryPaths,
@@ -2655,16 +2661,15 @@ export class GeminiClient {
goalPermit,
);
} else {
- // Only pass the payload when a hook actually injected; omitting
- // the third argument keeps existing two-arg spies/call sites
- // exact (passing `undefined` would still count as a third arg).
- const recordingService = this.config.getChatRecordingService();
- if (recordingService && preInjectionPromptText !== undefined) {
- recordingService.recordUserMessage(request, goalPermit, {
- displayText: preInjectionPromptText,
- });
- } else if (recordingService) {
- recordingService.recordUserMessage(request, goalPermit);
+ const recorder = this.config.getChatRecordingService();
+ if (userPromptRecordPayload) {
+ recorder?.recordUserMessage(
+ request,
+ goalPermit,
+ userPromptRecordPayload,
+ );
+ } else {
+ recorder?.recordUserMessage(request, goalPermit);
}
}
}
diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts
index 012158c6a74..b91a8f46737 100644
--- a/packages/core/src/core/prompts.test.ts
+++ b/packages/core/src/core/prompts.test.ts
@@ -74,6 +74,15 @@ describe('Core System Prompt (prompts.ts)', () => {
);
});
+ it('identifies UserPromptSubmit hook context as distinct from user input', () => {
+ vi.stubEnv('SANDBOX', undefined);
+ const prompt = getCoreSystemPrompt();
+
+ expect(prompt).toContain(
+ 'Text inside a `` tag is model context added by a configured `UserPromptSubmit` hook, not user input.',
+ );
+ });
+
it.each([
[
'interactive',
diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts
index 923184d1d69..72d1aa58f0a 100644
--- a/packages/core/src/core/prompts.ts
+++ b/packages/core/src/core/prompts.ts
@@ -299,6 +299,7 @@ ${coreIdentity}
# Core Mandates
+- **UserPromptSubmit Context:** Text inside a \`\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
diff --git a/packages/core/src/goals/goal-evidence.test.ts b/packages/core/src/goals/goal-evidence.test.ts
index 84a927497a0..a0d40f7ca49 100644
--- a/packages/core/src/goals/goal-evidence.test.ts
+++ b/packages/core/src/goals/goal-evidence.test.ts
@@ -368,6 +368,62 @@ describe('Goal evidence catalog', () => {
);
});
+ it('treats only display metadata as real-user evidence', () => {
+ const user = record('user', 'user', {
+ provenance: 'real_user',
+ turnId: 'turn-3',
+ text: 'expanded model prompt',
+ });
+ user.message?.parts?.push({
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ });
+ user.systemPayload = {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ };
+ const records = [record('cursor', 'system'), user];
+
+ const catalog = buildGoalEvidenceCatalog({
+ records,
+ goal: goal(),
+ permit: permit(),
+ });
+ const validated = validate(records, complete(['user']));
+
+ expect(catalog.entries[0]?.preview).toBe('raw @file prompt');
+ expect(validated.citedRecords[0]?.content).toBe('raw @file prompt');
+ expect(JSON.stringify({ catalog, validated })).not.toContain(
+ 'hook-only context',
+ );
+ });
+
+ it('keeps mid-turn model text instead of its display label', () => {
+ const modelText =
+ '[User message received during tool execution]: save logs';
+ const user = record('user', 'user', {
+ provenance: 'real_user',
+ subtype: 'mid_turn_user_message',
+ turnId: 'turn-3',
+ text: `\n${modelText}`,
+ });
+ user.systemPayload = { displayText: 'save logs' };
+ const records = [record('cursor', 'system'), user];
+
+ const catalog = buildGoalEvidenceCatalog({
+ records,
+ goal: goal(),
+ permit: permit(),
+ });
+ const validated = validate(records, complete(['user']));
+
+ expect(catalog.entries[0]?.preview).toBe(modelText);
+ expect(validated.citedRecords[0]?.content).toBe(modelText);
+ });
+
it.each([
['cursor_unset', null, [record('root', 'system')]],
['cursor_not_found', 'absent', [record('root', 'system')]],
diff --git a/packages/core/src/goals/goal-evidence.ts b/packages/core/src/goals/goal-evidence.ts
index cb50dd3f2af..a859a593aae 100644
--- a/packages/core/src/goals/goal-evidence.ts
+++ b/packages/core/src/goals/goal-evidence.ts
@@ -11,6 +11,7 @@ import {
type GoalTerminalProposal,
type GoalTurnPermit,
} from './goal-protocol.js';
+import { projectUserTranscriptForDisplay } from '../utils/transcript-records.js';
const CATALOG_PREVIEW_LIMIT = 240;
const CATALOG_ENTRY_LIMIT = 100;
@@ -37,6 +38,7 @@ export interface GoalEvidenceRecord {
provenance?: GoalRecordProvenance;
goalContext?: unknown;
message?: { parts?: Part[] };
+ systemPayload?: unknown;
}
export type GoalEvidenceProofKind =
@@ -539,8 +541,16 @@ function evidenceContent(
record: GoalEvidenceRecord,
provenance: GoalEvidenceProvenance,
): string {
+ const projection =
+ provenance === 'real_user'
+ ? projectUserTranscriptForDisplay(record)
+ : undefined;
+ if (projection?.displayText !== undefined) {
+ return projection.displayText.trim();
+ }
const content: string[] = [];
- for (const part of record.message?.parts ?? []) {
+ const parts = projection?.parts ?? record.message?.parts ?? [];
+ for (const part of parts) {
if (part.thought !== true && typeof part.text === 'string') {
content.push(part.text);
}
@@ -556,6 +566,13 @@ function evidencePreview(
record: GoalEvidenceRecord,
provenance: GoalEvidenceProvenance,
): string {
+ const projection =
+ provenance === 'real_user'
+ ? projectUserTranscriptForDisplay(record)
+ : undefined;
+ if (projection?.displayText !== undefined) {
+ return projection.displayText.slice(0, CATALOG_PREVIEW_LIMIT).trim();
+ }
let preview = '';
const append = (value: string) => {
if (!value || preview.length >= CATALOG_PREVIEW_LIMIT) return;
@@ -564,7 +581,8 @@ function evidencePreview(
preview += `${separator}${value}`.slice(0, remaining);
};
- for (const part of record.message?.parts ?? []) {
+ const parts = projection?.parts ?? record.message?.parts ?? [];
+ for (const part of parts) {
if (part.thought !== true && typeof part.text === 'string') {
append(part.text);
}
diff --git a/packages/core/src/hooks/user-prompt-submit-context.test.ts b/packages/core/src/hooks/user-prompt-submit-context.test.ts
index da826eedabc..217cf2bd9b3 100644
--- a/packages/core/src/hooks/user-prompt-submit-context.test.ts
+++ b/packages/core/src/hooks/user-prompt-submit-context.test.ts
@@ -6,12 +6,12 @@
import { describe, it, expect } from 'vitest';
import {
- wrapUserPromptSubmitContext,
isUserPromptSubmitContextPartText,
stripTrailingUserPromptSubmitContextPart,
USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG,
USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG,
} from './user-prompt-submit-context.js';
+import { wrapUserPromptSubmitContext } from '../utils/transcript-records.js';
describe('wrapUserPromptSubmitContext', () => {
it('wraps context between the open and close tags', () => {
@@ -80,6 +80,16 @@ describe('isUserPromptSubmitContextPartText', () => {
).toBe(false);
});
+ it('rejects nested reserved tags in the body', () => {
+ expect(
+ isUserPromptSubmitContextPartText(
+ wrapUserPromptSubmitContext(
+ `inner ${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG} tag`,
+ ),
+ ),
+ ).toBe(false);
+ });
+
it('rejects an unterminated open tag', () => {
expect(
isUserPromptSubmitContextPartText(
@@ -88,6 +98,14 @@ describe('isUserPromptSubmitContextPartText', () => {
).toBe(false);
});
+ it('rejects a complete block without newline delimiters', () => {
+ expect(
+ isUserPromptSubmitContextPartText(
+ `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}ctx${USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG}`,
+ ),
+ ).toBe(false);
+ });
+
it('rejects a lone close tag', () => {
expect(
isUserPromptSubmitContextPartText(USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG),
diff --git a/packages/core/src/hooks/user-prompt-submit-context.ts b/packages/core/src/hooks/user-prompt-submit-context.ts
index 8ae7eafc927..5d8ad6faa07 100644
--- a/packages/core/src/hooks/user-prompt-submit-context.ts
+++ b/packages/core/src/hooks/user-prompt-submit-context.ts
@@ -4,48 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
-/**
- * Reserved tag wrapping UserPromptSubmit `additionalContext` when it is
- * appended to the model-bound user message. The wrapper keeps hook-injected
- * text distinguishable from user-authored prose in model history, session
- * transcripts, and offline analysis.
- *
- * `getAdditionalContext()` escapes `<`/`>` in hook output, so injected
- * content can never contain a literal closing tag — a genuine wrapped part
- * is always a single, whole tagged block.
- */
-export const USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG =
- '';
-export const USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG =
- '';
+import { isUserPromptSubmitContextPartText as isUserPromptSubmitContextPartTextInternal } from '../utils/transcript-records.js';
/**
- * Wraps sanitized UserPromptSubmit additional context in the reserved tag.
- */
-export function wrapUserPromptSubmitContext(context: string): string {
- return `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}\n${context}\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG}`;
-}
-
-/**
- * Returns true when `text` is, in its entirety, a wrapped UserPromptSubmit
- * context block (allowing surrounding whitespace).
- *
- * Intended for display projection of records that carry the tag but no
- * `UserPromptRecordPayload` metadata: injection always appends the wrapped
- * context as its own whole part, so only a whole-part match may be treated
- * as hook-injected. Text where the tag is mixed with other prose is
- * user-authored and must never match.
+ * Reserved tag wrapping UserPromptSubmit `additionalContext` when it is
+ * appended to the model-bound user message. The canonical tag and matcher
+ * definitions live in the Node-free transcript-records module.
*/
-export function isUserPromptSubmitContextPartText(text: string): boolean {
- const trimmed = text.trim();
- return (
- trimmed.startsWith(USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG) &&
- trimmed.endsWith(USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG) &&
- trimmed.length >=
- USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG.length +
- USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG.length
- );
-}
+export {
+ USER_PROMPT_SUBMIT_CONTEXT_OPEN as USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG,
+ USER_PROMPT_SUBMIT_CONTEXT_CLOSE as USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG,
+ isUserPromptSubmitContextPartText,
+ wrapUserPromptSubmitContext,
+} from '../utils/transcript-records.js';
/**
* Drops a trailing part that is entirely a tagged UserPromptSubmit context
@@ -63,7 +34,7 @@ export function stripTrailingUserPromptSubmitContextPart(
if (
!last ||
typeof last.text !== 'string' ||
- !isUserPromptSubmitContextPartText(last.text)
+ !isUserPromptSubmitContextPartTextInternal(last.text)
) {
return parts;
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 052a8e93c98..6592c79d94a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -642,10 +642,10 @@ export { buildContextUsage } from './hooks/context-usage.js';
export {
USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG,
USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG,
- wrapUserPromptSubmitContext,
isUserPromptSubmitContextPartText,
stripTrailingUserPromptSubmitContextPart,
} from './hooks/user-prompt-submit-context.js';
+export { wrapUserPromptSubmitContext } from './utils/transcript-records.js';
// ============================================================================
// Goals (/goal command runtime)
diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts
index afcd1242dea..0d03f83e5c3 100644
--- a/packages/core/src/services/chatRecordingService.test.ts
+++ b/packages/core/src/services/chatRecordingService.test.ts
@@ -159,30 +159,56 @@ describe('ChatRecordingService', () => {
expect(record.provenance).toBe('real_user');
});
- it('stores hook display provenance in systemPayload only when provided', async () => {
- const taggedParts: Part[] = [
- { text: 'my prompt' },
+ it('preserves model-bound parts and records clean display text', async () => {
+ const modelParts: Part[] = [
+ { text: 'expanded model prompt' },
{
- text: '\nextra\n',
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
},
];
- chatRecordingService.recordUserMessage(taggedParts, undefined, {
- displayText: 'my prompt',
+
+ chatRecordingService.recordUserMessage(modelParts, undefined, {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
});
- chatRecordingService.recordUserMessage([{ text: 'plain prompt' }]);
await chatRecordingService.flush();
- const calls = vi.mocked(jsonl.writeLine).mock.calls;
- const augmented = calls[0][1] as ChatRecord;
- const plain = calls[1][1] as ChatRecord;
-
- // The model-bound parts are stored verbatim; the user-authored
- // projection travels separately in the payload.
- expect(augmented.message).toEqual({ role: 'user', parts: taggedParts });
- expect(augmented.systemPayload).toEqual({
- displayText: 'my prompt',
+ const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
+ expect(record.message).toEqual({ role: 'user', parts: modelParts });
+ expect(record.systemPayload).toEqual({
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ });
+ });
+
+ it('records empty display text without dropping prompt provenance', async () => {
+ chatRecordingService.recordUserMessage(
+ [
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ undefined,
+ {
+ displayText: '',
+ hookContext: 'hook-only context',
+ },
+ );
+ await chatRecordingService.flush();
+
+ const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
+ expect(record.systemPayload).toEqual({
+ displayText: '',
+ hookContext: 'hook-only context',
});
- expect(plain.systemPayload).toBeUndefined();
});
it('blocks later turns after a generic durable write failure', async () => {
diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts
index f5869a49295..24bb49550f8 100644
--- a/packages/core/src/services/chatRecordingService.ts
+++ b/packages/core/src/services/chatRecordingService.ts
@@ -393,19 +393,6 @@ export interface ChatRecord {
};
}
-/**
- * Stored payload for user-prompt records whose model-bound parts were
- * augmented by a UserPromptSubmit hook. `message` keeps the exact
- * model-bound Content (resume must replay what the model actually saw);
- * this payload preserves the user-authored projection for UI/resume
- * display. Hook-injected text stays recoverable from the tagged part in
- * `message.parts` via `isUserPromptSubmitContextPartText`.
- */
-export interface UserPromptRecordPayload {
- /** Pre-injection projection of the user's own prompt text. */
- displayText?: string;
-}
-
export interface NotificationRecordPayload {
displayText: string;
backgroundTask?: {
@@ -416,6 +403,16 @@ export interface NotificationRecordPayload {
};
}
+export interface UserPromptRecordPayload {
+ /**
+ * TUI submittedPrompt projection when available; otherwise the expanded
+ * pre-hook prompt.
+ */
+ displayText: string;
+ /** Sanitized hook context duplicated from the tagged model-bound part. */
+ hookContext: string;
+}
+
export interface AgentBootstrapRecordPayload {
/** Bootstrap kind for future-proof decoding. */
kind: 'fork';
@@ -1287,11 +1284,13 @@ export class ChatRecordingService {
* Queues the write immediately on the serialized async writer.
*
* @param message The raw PartListUnion object as used with the API
+ * @param goalContext Goal identity and turn that own this message
+ * @param promptPayload User-authored display text and hook-context provenance
*/
recordUserMessage(
message: PartListUnion,
goalContext?: GoalTurnPermit,
- payload?: UserPromptRecordPayload,
+ promptPayload?: UserPromptRecordPayload,
): void {
try {
this.turnParentUuids.push(this.lastRecordUuid);
@@ -1299,7 +1298,7 @@ export class ChatRecordingService {
...this.createBaseRecord('user'),
...(goalContext ? { goalContext: copyGoalContext(goalContext) } : {}),
message: createUserContent(message),
- ...(payload ? { systemPayload: payload } : {}),
+ ...(promptPayload ? { systemPayload: promptPayload } : {}),
};
this.appendRecord(record);
} catch (error) {
diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts
index f98c76b0f20..5fb6d2500b6 100644
--- a/packages/core/src/services/session-reference-service.test.ts
+++ b/packages/core/src/services/session-reference-service.test.ts
@@ -56,6 +56,62 @@ describe('SessionReferenceService', () => {
expect(res.text).not.toContain('reason');
});
+ it('uses clean user display metadata for referenced text and title', async () => {
+ const svc = makeSvc(
+ fakeResumed([
+ {
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ },
+ ]),
+ );
+
+ const res = await svc.resolve('s1');
+ if ('notFound' in res) throw new Error('unexpected');
+
+ expect(res.meta.title).toBe('raw @file prompt');
+ expect(res.text).toContain('User: raw @file prompt');
+ expect(res.text).not.toContain('hook-only context');
+ });
+
+ it('keeps notification model text instead of its display label', async () => {
+ const svc = makeSvc(
+ fakeResumed([
+ {
+ type: 'user',
+ subtype: 'notification',
+ message: {
+ role: 'user',
+ parts: [{ text: 'notification model text' }],
+ },
+ systemPayload: { displayText: 'Background agent completed' },
+ },
+ ]),
+ );
+
+ const res = await svc.resolve('s1');
+ if ('notFound' in res) throw new Error('unexpected');
+
+ expect(res.text).toContain('User: notification model text');
+ expect(res.text).not.toContain('Background agent completed');
+ });
+
it('collapses tool calls to one-line summaries without result bodies', async () => {
const svc = makeSvc(
fakeResumed([
diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts
index 31998154729..41c1c2e0776 100644
--- a/packages/core/src/services/session-reference-service.ts
+++ b/packages/core/src/services/session-reference-service.ts
@@ -8,6 +8,7 @@ import type { Content, Part } from '@google/genai';
import { SessionService } from './sessionService.js';
import type { ChatRecord } from './chatRecordingService.js';
import { estimateContentTokens } from './tokenEstimation.js';
+import { projectUserTranscriptForDisplay } from '../utils/transcript-records.js';
/** Default token budget for an injected slimmed session reference. */
export const SESSION_REF_TOKEN_BUDGET = 8000;
@@ -134,7 +135,7 @@ export class SessionReferenceService {
// so we must emit its text here rather than short-circuiting on the tool
// parts (which would silently drop the assistant's reasoning).
if (rec.type === 'user') {
- const text = this.visibleText(rec.message);
+ const text = this.visibleUserText(rec);
if (text) out.push(`User: ${text}`);
} else if (rec.type === 'assistant') {
const text = this.visibleText(rec.message);
@@ -160,8 +161,19 @@ export class SessionReferenceService {
}
private visibleText(message?: Content): string {
- if (!message?.parts) return '';
- return message.parts
+ return this.visibleTextParts(message?.parts ?? []);
+ }
+
+ private visibleUserText(record: ChatRecord): string {
+ const projection = projectUserTranscriptForDisplay(record);
+ if (projection.displayText !== undefined) {
+ return projection.displayText.trim();
+ }
+ return this.visibleTextParts(projection.parts);
+ }
+
+ private visibleTextParts(parts: readonly Part[]): string {
+ return parts
.filter((p: Part) => !(p as ThoughtPart).thought && p.text)
.map((p: Part) => p.text)
.join('')
@@ -185,7 +197,7 @@ export class SessionReferenceService {
if (customTitle) return customTitle;
for (const rec of records) {
if (rec.type !== 'user') continue;
- const text = this.visibleText(rec.message);
+ const text = this.visibleUserText(rec);
if (!text) continue;
const firstLine = text.split('\n')[0].trim();
if (firstLine.length === 0) continue;
diff --git a/packages/core/src/utils/conversation-branches.test.ts b/packages/core/src/utils/conversation-branches.test.ts
index 9d49513b2fe..dfc4f4b9b2c 100644
--- a/packages/core/src/utils/conversation-branches.test.ts
+++ b/packages/core/src/utils/conversation-branches.test.ts
@@ -113,6 +113,36 @@ describe('inspectConversationBranches', () => {
]);
});
+ it('summarizes user records from clean display metadata', () => {
+ const records = [
+ record('root-user', null, {
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ }),
+ assistant('only-answer', 'root-user', 'only answer'),
+ ];
+
+ expect(inspectConversationBranches(records).branches[0]).toMatchObject({
+ firstUserTextAfterBranchPoint: 'raw @file prompt',
+ lastUserText: 'raw @file prompt',
+ });
+ });
+
it('counts tool results in a branch chain', () => {
const records = [
record('root-user', null),
diff --git a/packages/core/src/utils/conversation-branches.ts b/packages/core/src/utils/conversation-branches.ts
index 04c44445315..ee5bb93552f 100644
--- a/packages/core/src/utils/conversation-branches.ts
+++ b/packages/core/src/utils/conversation-branches.ts
@@ -5,6 +5,7 @@
*/
import type { ChatRecord } from '../services/chatRecordingService.js';
+import { projectUserTranscriptForDisplay } from './transcript-records.js';
const SUMMARY_TEXT_LIMIT = 200;
const SYNTHETIC_USER_SUBTYPES = new Set([
@@ -437,7 +438,13 @@ function extractText(
record.type === type &&
(type !== 'user' || !isSyntheticUserRecord(record)),
)
- .flatMap((record) => record.message?.parts ?? [])
+ .flatMap((record) => {
+ if (type !== 'user') return record.message?.parts ?? [];
+ const projection = projectUserTranscriptForDisplay(record);
+ return projection.displayText !== undefined
+ ? [{ text: projection.displayText }]
+ : projection.parts;
+ })
.map((part) => {
const textPart = part as { text?: unknown; thought?: unknown };
return typeof textPart.text === 'string' && textPart.thought !== true
diff --git a/packages/core/src/utils/transcript-records.test.ts b/packages/core/src/utils/transcript-records.test.ts
index 38f7a4f6450..f6d027377be 100644
--- a/packages/core/src/utils/transcript-records.test.ts
+++ b/packages/core/src/utils/transcript-records.test.ts
@@ -7,6 +7,8 @@
import { describe, expect, it } from 'vitest';
import {
prepareTranscriptRecords,
+ projectUserTranscriptForDisplay,
+ wrapUserPromptSubmitContext,
type TranscriptRecordPreparationError,
} from './transcript-records.js';
@@ -216,3 +218,111 @@ describe('prepareTranscriptRecords', () => {
);
});
});
+
+describe('projectUserTranscriptForDisplay', () => {
+ it('uses display metadata even when the display text is empty', () => {
+ const imagePart = {
+ inlineData: { mimeType: 'image/png', data: 'data' },
+ };
+ expect(
+ projectUserTranscriptForDisplay({
+ message: {
+ parts: [
+ imagePart,
+ { text: wrapUserPromptSubmitContext('hook context') },
+ ],
+ },
+ systemPayload: { displayText: '', hookContext: 'hook context' },
+ }),
+ ).toEqual({ displayText: '', parts: [imagePart] });
+ });
+
+ it('uses released single-field display metadata when the final tag proves provenance', () => {
+ const imagePart = {
+ inlineData: { mimeType: 'image/png', data: 'data' },
+ };
+ expect(
+ projectUserTranscriptForDisplay({
+ message: {
+ parts: [
+ imagePart,
+ { text: 'expanded model prompt' },
+ { text: wrapUserPromptSubmitContext('hook context') },
+ ],
+ },
+ systemPayload: { displayText: 'raw @file prompt' },
+ }),
+ ).toEqual({ displayText: 'raw @file prompt', parts: [imagePart] });
+ });
+
+ it('does not treat notification display labels as user prompt metadata', () => {
+ const modelPart = { text: 'notification model text' };
+ expect(
+ projectUserTranscriptForDisplay({
+ message: { parts: [modelPart] },
+ systemPayload: { displayText: 'Background agent completed' },
+ }),
+ ).toEqual({ displayText: undefined, parts: [modelPart] });
+ });
+
+ it('removes only a complete final tag-only context part', () => {
+ const userPart = { text: 'user text' };
+ expect(
+ projectUserTranscriptForDisplay({
+ message: {
+ parts: [
+ userPart,
+ { text: wrapUserPromptSubmitContext('hook context') },
+ ],
+ },
+ }),
+ ).toEqual({ displayText: undefined, parts: [userPart] });
+ });
+
+ it('treats non-object system payloads as absent metadata', () => {
+ const userPart = { text: 'user text' };
+ const taggedPart = {
+ text: wrapUserPromptSubmitContext('hook context'),
+ };
+
+ expect(
+ projectUserTranscriptForDisplay({
+ message: { parts: [userPart, taggedPart] },
+ systemPayload: null,
+ }),
+ ).toEqual({ displayText: undefined, parts: [userPart] });
+ });
+
+ it('preserves legacy bare context and user-authored tag-like text', () => {
+ const legacyParts = [{ text: 'user text' }, { text: 'bare hook context' }];
+ expect(
+ projectUserTranscriptForDisplay({
+ message: { parts: legacyParts },
+ }),
+ ).toEqual({ displayText: undefined, parts: legacyParts });
+
+ const userAuthoredTag = {
+ text: wrapUserPromptSubmitContext('user-authored text'),
+ };
+ expect(
+ projectUserTranscriptForDisplay({
+ message: { parts: [userAuthoredTag] },
+ }),
+ ).toEqual({ displayText: undefined, parts: [userAuthoredTag] });
+ });
+
+ it('does not trust bare displayText without a final context tag', () => {
+ const taggedPart = {
+ text: 'user-authored text',
+ };
+ expect(
+ projectUserTranscriptForDisplay({
+ message: { parts: [{ text: 'user text' }, taggedPart] },
+ systemPayload: { displayText: 'notification label' },
+ }),
+ ).toEqual({
+ displayText: undefined,
+ parts: [{ text: 'user text' }, taggedPart],
+ });
+ });
+});
diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts
index 889149a3830..7e167005d73 100644
--- a/packages/core/src/utils/transcript-records.ts
+++ b/packages/core/src/utils/transcript-records.ts
@@ -41,6 +41,24 @@ export interface TranscriptRecordInput {
};
}
+export const USER_PROMPT_SUBMIT_CONTEXT_OPEN =
+ '';
+export const USER_PROMPT_SUBMIT_CONTEXT_CLOSE =
+ '';
+
+export interface UserTranscriptDisplayProjection {
+ /**
+ * Authoritative pre-hook display text when provenance metadata is present.
+ * An empty string is meaningful and must not fall back to model-facing parts.
+ */
+ readonly displayText: string | undefined;
+ /**
+ * User-visible model parts. With display metadata, text parts are omitted in
+ * favor of `displayText`; non-text parts (for example images) are retained.
+ */
+ readonly parts: readonly TPart[];
+}
+
export interface TranscriptReplayGapInput {
readonly childUuid: string;
readonly missingParentUuid: string;
@@ -116,6 +134,73 @@ function isObjectRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+export function wrapUserPromptSubmitContext(context: string): string {
+ return `${USER_PROMPT_SUBMIT_CONTEXT_OPEN}\n${context}\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE}`;
+}
+
+export function isUserPromptSubmitContextPartText(text: string): boolean {
+ const trimmed = text.trim();
+ const prefix = `${USER_PROMPT_SUBMIT_CONTEXT_OPEN}\n`;
+ const suffix = `\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE}`;
+ if (!trimmed.startsWith(prefix) || !trimmed.endsWith(suffix)) {
+ return false;
+ }
+ const body = trimmed.slice(prefix.length, -suffix.length);
+ return (
+ !body.includes(USER_PROMPT_SUBMIT_CONTEXT_OPEN) &&
+ !body.includes(USER_PROMPT_SUBMIT_CONTEXT_CLOSE)
+ );
+}
+
+function isUserPromptSubmitContextPart(part: unknown): boolean {
+ return (
+ isObjectRecord(part) &&
+ typeof part['text'] === 'string' &&
+ isUserPromptSubmitContextPartText(part['text'])
+ );
+}
+
+/**
+ * Selects the user-visible projection of a transcript record.
+ *
+ * New user-prompt records pair authoritative `systemPayload.displayText` with
+ * `hookContext` provenance. Released `displayText`-only records use a complete
+ * final hook-context part as equivalent pairing evidence. For tag-only
+ * third-party records with no metadata, only that complete final part is
+ * removed. Legacy records without either shape retain their model-facing parts.
+ */
+export function projectUserTranscriptForDisplay(record: {
+ readonly message?: {
+ readonly parts?: readonly TPart[];
+ };
+ readonly systemPayload?: unknown;
+}): UserTranscriptDisplayProjection {
+ const parts = record.message?.parts ?? [];
+ const hasFinalHookContextPart =
+ parts.length > 1 && isUserPromptSubmitContextPart(parts[parts.length - 1]);
+ const payload = isObjectRecord(record.systemPayload)
+ ? record.systemPayload
+ : undefined;
+ const isUserPromptPayload =
+ payload &&
+ (typeof payload['hookContext'] === 'string' || hasFinalHookContextPart);
+ const displayText =
+ isUserPromptPayload && typeof payload['displayText'] === 'string'
+ ? payload['displayText']
+ : undefined;
+ if (displayText !== undefined) {
+ const visibleParts = parts.filter(
+ (part) => !isObjectRecord(part) || typeof part['text'] !== 'string',
+ );
+ return { displayText, parts: visibleParts };
+ }
+
+ if (payload === undefined && hasFinalHookContextPart) {
+ return { displayText: undefined, parts: parts.slice(0, -1) };
+ }
+ return { displayText: undefined, parts };
+}
+
function diagnostic(
code: string,
message: string,
diff --git a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts
index 90c4389dc58..aea8c631e90 100644
--- a/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts
+++ b/packages/desktop/packages/shared/src/agent/__tests__/qwen-agent-slash-history.test.ts
@@ -15,6 +15,7 @@ import type { FileAttachment } from '../../utils/files.ts';
type QwenAgentConfig = ConstructorParameters[0];
type QwenHistoryInternals = {
+ extractQwenRecordText: (record: Record) => string;
mergeSlashCommandInvocationMessages: (
sessionId: string,
messages: Message[],
@@ -211,6 +212,75 @@ describe('QwenAgent slash command history', () => {
}
});
+ it('projects Qwen user transcript records without hook context', () => {
+ const extractQwenRecordText = (
+ QwenAgent.prototype as unknown as QwenHistoryInternals
+ ).extractQwenRecordText;
+ const hookContext =
+ '\ntrusted context\n';
+
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: {
+ parts: [{ text: 'expanded prompt' }, { text: hookContext }],
+ },
+ systemPayload: {
+ displayText: 'original prompt',
+ hookContext: 'trusted context',
+ },
+ }),
+ ).toBe('original prompt');
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: {
+ parts: [{ text: 'expanded prompt' }, { text: hookContext }],
+ },
+ systemPayload: { displayText: '', hookContext: 'trusted context' },
+ }),
+ ).toBe('');
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: {
+ parts: [{ text: 'tag-only prompt' }, { text: hookContext }],
+ },
+ }),
+ ).toBe('tag-only prompt');
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: { parts: [{ text: 'legacy prompt' }] },
+ }),
+ ).toBe('legacy prompt');
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: { parts: [{ text: 'notification model text' }] },
+ systemPayload: { displayText: 'Background agent completed' },
+ }),
+ ).toBe('notification model text');
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: {
+ parts: [{ text: 'user prompt' }, { text: hookContext }],
+ },
+ systemPayload: { displayText: 'raw @file prompt' },
+ }),
+ ).toBe('raw @file prompt');
+ expect(
+ extractQwenRecordText({
+ type: 'user',
+ message: {
+ parts: [{ text: 'user prompt' }, { text: hookContext }],
+ },
+ systemPayload: null,
+ }),
+ ).toBe('user prompt');
+ });
+
it('sends slash commands as raw ACP prompts', () => {
const blocks = (
QwenAgent.prototype as unknown as QwenPromptInternals
@@ -1935,6 +2005,60 @@ describe('QwenAgent slash command history', () => {
]);
});
+ it('does not re-add hook-expanded transcript users after ACP history replay', async () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-'));
+ const runtimeRoot = mkdtempSync(join(tmpdir(), 'qwen-runtime-'));
+ tempRoots.push(cwd, runtimeRoot);
+ process.env.QWEN_RUNTIME_DIR = runtimeRoot;
+
+ const sessionId = 'qwen-session';
+ writeQwenTranscript(runtimeRoot, cwd, sessionId, [
+ {
+ uuid: 'user-1',
+ sessionId,
+ timestamp: '1970-01-01T00:00:01.000Z',
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [
+ { text: 'expanded prompt' },
+ {
+ text: '\ntrusted context\n',
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: 'original prompt',
+ hookContext: 'trusted context',
+ },
+ },
+ ]);
+
+ const agent = createAgent(cwd);
+ const internals = agent as unknown as QwenAvailableCommandsInternals;
+ internals.ensureProcess = async () => {};
+ internals.callAcp = async (_method, execute) =>
+ execute({
+ extMethod: async () => ({
+ updates: [
+ {
+ sessionUpdate: 'user_message_chunk',
+ content: { type: 'text', text: 'original prompt' },
+ timestamp: 1_000,
+ },
+ ],
+ }),
+ loadSession: async () => ({ models: {}, modes: {} }),
+ });
+
+ const result = await agent.loadSessionMessages(sessionId, { cwd });
+ agent.destroy();
+
+ expect(
+ result.messages.map((message) => [message.role, message.content]),
+ ).toEqual([['user', 'original prompt']]);
+ });
+
it('restores Qwen transcript API aborts as interrupted info', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'qwen-cwd-'));
const runtimeRoot = mkdtempSync(join(tmpdir(), 'qwen-runtime-'));
diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts
index 37010558276..e4620967a70 100644
--- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts
+++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts
@@ -963,6 +963,53 @@ function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
+// Keep these in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE in Qwen core.
+const QWEN_USER_PROMPT_CONTEXT_OPEN = '';
+const QWEN_USER_PROMPT_CONTEXT_CLOSE = '';
+
+function isQwenUserPromptContextPart(part: unknown): boolean {
+ if (!isRecord(part) || typeof part.text !== 'string') return false;
+ const text = part.text.trim();
+ const prefix = `${QWEN_USER_PROMPT_CONTEXT_OPEN}\n`;
+ const suffix = `\n${QWEN_USER_PROMPT_CONTEXT_CLOSE}`;
+ if (!text.startsWith(prefix) || !text.endsWith(suffix)) {
+ return false;
+ }
+ const body = text.slice(prefix.length, -suffix.length);
+ return (
+ !body.includes(QWEN_USER_PROMPT_CONTEXT_OPEN) &&
+ !body.includes(QWEN_USER_PROMPT_CONTEXT_CLOSE)
+ );
+}
+
+function projectQwenUserRecordText(record: JsonRecord): string {
+ const message = toRecord(record.message);
+ const parts = Array.isArray(message.parts)
+ ? message.parts.filter(isRecord)
+ : [];
+ const hasFinalHookContextPart =
+ parts.length > 1 &&
+ isQwenUserPromptContextPart(parts[parts.length - 1]);
+ const payload = isRecord(record.systemPayload)
+ ? record.systemPayload
+ : undefined;
+ const displayText =
+ payload &&
+ (asString(payload.hookContext) !== undefined || hasFinalHookContextPart)
+ ? asString(payload.displayText)
+ : undefined;
+ if (displayText !== undefined) return displayText;
+
+ const visibleParts =
+ payload === undefined && hasFinalHookContextPart
+ ? parts.slice(0, -1)
+ : parts;
+ return visibleParts
+ .map((part) => asString(part.text))
+ .filter((text): text is string => !!text)
+ .join('\n\n');
+}
+
export function extractQwenParentToolUseId(
update: Record,
): string | undefined {
@@ -3398,6 +3445,9 @@ export class QwenAgent extends BaseAgent {
}
private extractQwenRecordText(record: JsonRecord): string {
+ if (record.type === 'user') {
+ return projectQwenUserRecordText(record);
+ }
const message = toRecord(record.message);
const parts = Array.isArray(message.parts)
? message.parts.filter(isRecord)
diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts
index 667c5d4c702..734eb7b21cb 100644
--- a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts
+++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts
@@ -5,6 +5,9 @@
*/
import { describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import {
extractSessionListItems,
QwenAgentManager,
@@ -174,3 +177,54 @@ describe('QwenAgentManager.createNewSession', () => {
expect(connection.newSession).toHaveBeenCalledWith('/workspace');
});
});
+
+describe('QwenAgentManager.getSessionMessages', () => {
+ it('projects UserPromptSubmit provenance while mapping JSONL history', async () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'qwen-agent-manager-'));
+ const filePath = join(tempDir, 'session.jsonl');
+ const timestamp = '2026-03-22T16:48:35.000Z';
+ const taggedContext =
+ '\nhook-only context\n';
+ writeFileSync(
+ filePath,
+ `${JSON.stringify({
+ sessionId: 'session-1',
+ uuid: 'user-1',
+ timestamp,
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [{ text: 'expanded model prompt' }, { text: taggedContext }],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ })}\n`,
+ );
+
+ try {
+ const manager = new QwenAgentManager();
+ vi.spyOn(manager, 'getSessionList').mockResolvedValue([
+ {
+ id: 'session-1',
+ sessionId: 'session-1',
+ filePath,
+ },
+ ]);
+
+ const messages = await manager.getSessionMessages('session-1');
+
+ expect(messages).toEqual([
+ {
+ role: 'user',
+ content: 'raw @file prompt',
+ timestamp: new Date(timestamp).getTime(),
+ },
+ ]);
+ expect(messages[0]?.content).not.toContain('hook-only context');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts
index 33539f4db9a..8149c19fdeb 100644
--- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts
+++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts
@@ -19,6 +19,7 @@ import type {
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
+import { qwenContentToText, qwenRecordToText } from './qwenTranscriptText.js';
import { QwenSessionManager } from './qwenSessionManager.js';
import type {
ChatMessage,
@@ -814,7 +815,10 @@ export class QwenAgentManager {
msgs.push({
role:
r.type === 'user' ? ('user' as const) : ('assistant' as const),
- content: this.contentToText(r.message),
+ content:
+ r.type === 'user'
+ ? qwenRecordToText(r)
+ : qwenContentToText(r.message),
timestamp: new Date(r.timestamp).getTime(),
});
}
@@ -1028,38 +1032,6 @@ export class QwenAgentManager {
return String(value);
}
- // Extract plain text from Content (genai Content)
- private contentToText(message: unknown): string {
- try {
- // Type guard for message
- if (typeof message !== 'object' || message === null) {
- return '';
- }
-
- // Cast to a more specific type for easier handling
- const typedMessage = message as Record;
-
- const parts = Array.isArray(typedMessage.parts) ? typedMessage.parts : [];
- const texts: string[] = [];
- for (const p of parts) {
- // Type guard for part
- if (typeof p !== 'object' || p === null) {
- continue;
- }
-
- const typedPart = p as Record;
- if (typeof typedPart.text === 'string') {
- texts.push(typedPart.text);
- } else if (typeof typedPart.data === 'string') {
- texts.push(typedPart.data);
- }
- }
- return texts.join('\n');
- } catch {
- return '';
- }
- }
-
/**
* Try to load session via ACP session/load method
* This method will only be used if CLI version supports it
diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.test.ts
new file mode 100644
index 00000000000..c34d9285793
--- /dev/null
+++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.test.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { resetEnvBootstrapForTesting } from '../utils/paths.js';
+import { QwenSessionReader } from './qwenSessionReader.js';
+
+const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
+let runtimeDir: string;
+
+beforeEach(() => {
+ runtimeDir = mkdtempSync(join(tmpdir(), 'qwen-session-reader-'));
+ process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
+ resetEnvBootstrapForTesting();
+});
+
+afterEach(() => {
+ if (originalRuntimeDir !== undefined) {
+ process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
+ } else {
+ delete process.env['QWEN_RUNTIME_DIR'];
+ }
+ resetEnvBootstrapForTesting();
+ rmSync(runtimeDir, { recursive: true, force: true });
+});
+
+describe('QwenSessionReader', () => {
+ it('projects UserPromptSubmit provenance in summaries and hydrated messages', async () => {
+ const sessionId = '11111111-1111-1111-1111-111111111111';
+ const chatsDir = join(runtimeDir, 'tmp', 'project-hash', 'chats');
+ mkdirSync(chatsDir, { recursive: true });
+ const taggedContext =
+ '\nhook-only context\n';
+ writeFileSync(
+ join(chatsDir, `${sessionId}.jsonl`),
+ `${JSON.stringify({
+ sessionId,
+ uuid: 'user-1',
+ timestamp: '2026-03-22T16:48:35.000Z',
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [{ text: 'expanded model prompt' }, { text: taggedContext }],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ })}\n`,
+ );
+
+ const reader = new QwenSessionReader();
+ const summaries = await reader.getAllSessions(undefined, true);
+ const hydrated = await reader.getSession(sessionId);
+
+ expect(summaries).toHaveLength(1);
+ expect(summaries[0]?.firstUserText).toBe('raw @file prompt');
+ expect(hydrated?.messages).toMatchObject([
+ { type: 'user', content: 'raw @file prompt' },
+ ]);
+ expect(hydrated?.messages[0]?.content).not.toContain('hook-only context');
+ });
+});
diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts
index e35b52683a8..4260ff9d9e2 100644
--- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts
+++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts
@@ -12,6 +12,7 @@ import * as crypto from 'crypto';
import { getGitBranch, getProjectHash } from '@qwen-code/qwen-code-core';
import { getRuntimeBaseDir } from '../utils/paths.js';
import { truncatePanelTitle } from '../webview/utils/panelTitleUtils.js';
+import { qwenContentToText, qwenRecordToText } from './qwenTranscriptText.js';
export interface QwenMessage {
id: string;
@@ -261,7 +262,10 @@ export class QwenSessionReader {
seenUuids.add(uuid);
}
- const text = this.contentToText(obj.message);
+ const text =
+ type === 'user'
+ ? qwenRecordToText(obj)
+ : qwenContentToText(obj.message);
if (includeMessages) {
messages.push({
id: uuid || `${messages.length}`,
@@ -319,33 +323,6 @@ export class QwenSessionReader {
}
}
- // Extract plain text from CLI Content structure
- private contentToText(message: unknown): string {
- try {
- if (typeof message !== 'object' || message === null) {
- return '';
- }
-
- const typed = message as { parts?: unknown[] };
- const parts = Array.isArray(typed.parts) ? typed.parts : [];
- const texts: string[] = [];
- for (const part of parts) {
- if (typeof part !== 'object' || part === null) {
- continue;
- }
- const p = part as Record;
- if (typeof p.text === 'string') {
- texts.push(p.text);
- } else if (typeof p.data === 'string') {
- texts.push(p.data);
- }
- }
- return texts.join('\n');
- } catch {
- return '';
- }
- }
-
/**
* Reads the UUID of the last record in a JSONL file via tail-read.
*/
diff --git a/packages/vscode-ide-companion/src/services/qwenTranscriptText.test.ts b/packages/vscode-ide-companion/src/services/qwenTranscriptText.test.ts
new file mode 100644
index 00000000000..c6815ee3392
--- /dev/null
+++ b/packages/vscode-ide-companion/src/services/qwenTranscriptText.test.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { qwenRecordToText } from './qwenTranscriptText.js';
+
+describe('qwenRecordToText', () => {
+ it('uses display metadata, including an empty display projection', () => {
+ const record = {
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ };
+
+ expect(qwenRecordToText(record)).toBe('raw @file prompt');
+ expect(
+ qwenRecordToText({
+ ...record,
+ systemPayload: { ...record.systemPayload, displayText: '' },
+ }),
+ ).toBe('');
+ });
+
+ it('keeps synthetic user model text instead of its display label', () => {
+ expect(
+ qwenRecordToText({
+ type: 'user',
+ message: { parts: [{ text: 'notification model text' }] },
+ systemPayload: { displayText: 'Background agent completed' },
+ }),
+ ).toBe('notification model text');
+ });
+
+ it('strips a complete final tag-only context part', () => {
+ expect(
+ qwenRecordToText({
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'user prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ },
+ }),
+ ).toBe('user prompt');
+ });
+
+ it('preserves legacy bare context without a reliable boundary', () => {
+ expect(
+ qwenRecordToText({
+ type: 'user',
+ message: {
+ parts: [
+ { text: 'user prompt' },
+ { text: 'legacy bare hook context' },
+ ],
+ },
+ }),
+ ).toBe('user prompt\nlegacy bare hook context');
+ });
+});
diff --git a/packages/vscode-ide-companion/src/services/qwenTranscriptText.ts b/packages/vscode-ide-companion/src/services/qwenTranscriptText.ts
new file mode 100644
index 00000000000..c768091e41f
--- /dev/null
+++ b/packages/vscode-ide-companion/src/services/qwenTranscriptText.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { projectUserTranscriptForDisplay } from '@qwen-code/qwen-code-core';
+
+type QwenTextRecord = {
+ type?: unknown;
+ message?: unknown;
+ systemPayload?: unknown;
+};
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function messageParts(message: unknown): readonly unknown[] {
+ if (!isRecord(message) || !Array.isArray(message.parts)) return [];
+ return message.parts;
+}
+
+function partsToText(parts: readonly unknown[]): string {
+ const texts: string[] = [];
+ for (const part of parts) {
+ if (!isRecord(part)) continue;
+ if (typeof part.text === 'string') {
+ texts.push(part.text);
+ } else if (typeof part.data === 'string') {
+ texts.push(part.data);
+ }
+ }
+ return texts.join('\n');
+}
+
+export function qwenContentToText(message: unknown): string {
+ return partsToText(messageParts(message));
+}
+
+export function qwenRecordToText(record: QwenTextRecord): string {
+ if (record.type !== 'user') return qwenContentToText(record.message);
+
+ const projection = projectUserTranscriptForDisplay({
+ message: { parts: messageParts(record.message) },
+ systemPayload: record.systemPayload,
+ });
+ return projection.displayText ?? partsToText(projection.parts);
+}
diff --git a/packages/webui/src/adapters/JSONLAdapter.test.ts b/packages/webui/src/adapters/JSONLAdapter.test.ts
new file mode 100644
index 00000000000..08a8c3adb72
--- /dev/null
+++ b/packages/webui/src/adapters/JSONLAdapter.test.ts
@@ -0,0 +1,149 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { adaptJSONLMessages } from './JSONLAdapter.js';
+import type { JSONLMessage } from './types.js';
+
+function userMessage(
+ parts: Array<{ text: string }>,
+ systemPayload?: unknown,
+): JSONLMessage {
+ return {
+ uuid: 'user-1',
+ timestamp: '2026-07-28T00:00:00.000Z',
+ type: 'user',
+ message: { role: 'user', parts },
+ ...(systemPayload === undefined ? {} : { systemPayload }),
+ };
+}
+
+describe('adaptJSONLMessages user display projection', () => {
+ it('uses display metadata without exposing model-only parts', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage(
+ [
+ { text: 'expanded model prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ {
+ displayText: 'raw @file prompt',
+ hookContext: 'hook-only context',
+ },
+ ),
+ ]);
+
+ expect(message?.content).toBe('raw @file prompt');
+ });
+
+ it('treats empty paired displayText as meaningful', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage([{ text: 'expanded model prompt' }], {
+ displayText: '',
+ hookContext: 'hook-only context',
+ }),
+ ]);
+
+ expect(message?.content).toBe('');
+ });
+
+ it('keeps notification model text instead of its display label', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage([{ text: 'notification model text' }], {
+ displayText: 'Background agent completed',
+ }),
+ ]);
+
+ expect(message?.content).toBe('notification model text');
+ });
+
+ it('strips a complete final tag-only context part', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage([
+ { text: 'user prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ]),
+ ]);
+
+ expect(message?.content).toBe('user prompt');
+ });
+
+ it('strips a final tag-only context part with invalid metadata', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage(
+ [
+ { text: 'user prompt' },
+ {
+ text: [
+ '',
+ 'hook-only context',
+ '',
+ ].join('\n'),
+ },
+ ],
+ null,
+ ),
+ ]);
+
+ expect(message?.content).toBe('user prompt');
+ });
+
+ it('preserves legacy bare-part concatenation without a reliable boundary', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage([
+ { text: 'user prompt' },
+ { text: 'legacy bare hook context' },
+ ]),
+ ]);
+
+ expect(message?.content).toBe('user promptlegacy bare hook context');
+ });
+
+ it('uses released single-field display metadata when the final tag proves provenance', () => {
+ const [message] = adaptJSONLMessages([
+ userMessage(
+ [
+ { text: 'user prompt' },
+ {
+ text: [
+ '',
+ 'user-authored text',
+ '',
+ ].join('\n'),
+ },
+ ],
+ { displayText: 'raw @file prompt' },
+ ),
+ ]);
+
+ expect(message?.content).toBe('raw @file prompt');
+ });
+
+ it('leaves non-Qwen user records to the existing format parser', () => {
+ const [message] = adaptJSONLMessages([
+ {
+ uuid: 'claude-user-1',
+ timestamp: '2026-07-28T00:00:00.000Z',
+ type: 'user',
+ message: { role: 'user', content: 'Claude user prompt' },
+ },
+ ]);
+
+ expect(message?.content).toBe('Claude user prompt');
+ });
+});
diff --git a/packages/webui/src/adapters/JSONLAdapter.ts b/packages/webui/src/adapters/JSONLAdapter.ts
index 51b2c933375..62938c99bf9 100644
--- a/packages/webui/src/adapters/JSONLAdapter.ts
+++ b/packages/webui/src/adapters/JSONLAdapter.ts
@@ -11,6 +11,7 @@ import type {
JSONLMessage,
UnifiedMessageType,
} from './types.js';
+import { getUserTranscriptDisplayText } from './userTranscriptDisplay.js';
/**
* Extract text content from different message formats
@@ -100,12 +101,16 @@ export function adaptJSONLMessages(messages: JSONLMessage[]): UnifiedMessage[] {
const isLast = isUserType(next);
const type = getMessageType(msg);
+ const userContent = getUserTranscriptDisplayText(msg);
return {
id: msg.uuid,
type,
timestamp: parseTimestamp(msg.timestamp),
- content: type !== 'tool_call' ? extractContent(msg.message) : undefined,
+ content:
+ type !== 'tool_call'
+ ? (userContent ?? extractContent(msg.message))
+ : undefined,
toolCall: msg.toolCall,
isFirst,
isLast,
diff --git a/packages/webui/src/adapters/types.ts b/packages/webui/src/adapters/types.ts
index 70c936c229a..b88ddc45d87 100644
--- a/packages/webui/src/adapters/types.ts
+++ b/packages/webui/src/adapters/types.ts
@@ -57,6 +57,7 @@ export interface JSONLMessage {
parts?: Array<{ text: string }>; // Qwen format
content?: string | unknown[]; // Claude format
};
+ systemPayload?: unknown;
model?: string;
toolCall?: ToolCallData;
}
diff --git a/packages/webui/src/adapters/userTranscriptDisplay.ts b/packages/webui/src/adapters/userTranscriptDisplay.ts
new file mode 100644
index 00000000000..d168e5993a0
--- /dev/null
+++ b/packages/webui/src/adapters/userTranscriptDisplay.ts
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Keep these in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE in core.
+const USER_PROMPT_CONTEXT_OPEN = '';
+const USER_PROMPT_CONTEXT_CLOSE = '';
+
+type UserTranscriptRecord = {
+ type?: unknown;
+ message?: {
+ parts?: readonly unknown[];
+ };
+ systemPayload?: unknown;
+};
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isHookContextPart(part: unknown): boolean {
+ if (!isRecord(part) || typeof part.text !== 'string') return false;
+ const text = part.text.trim();
+ const prefix = `${USER_PROMPT_CONTEXT_OPEN}\n`;
+ const suffix = `\n${USER_PROMPT_CONTEXT_CLOSE}`;
+ if (!text.startsWith(prefix) || !text.endsWith(suffix)) {
+ return false;
+ }
+ const body = text.slice(prefix.length, -suffix.length);
+ return (
+ !body.includes(USER_PROMPT_CONTEXT_OPEN) &&
+ !body.includes(USER_PROMPT_CONTEXT_CLOSE)
+ );
+}
+
+/**
+ * Returns a user record's clean text projection. `undefined` means the record
+ * is not a Qwen user record and the caller should use its other format parser.
+ */
+export function getUserTranscriptDisplayText(
+ record: UserTranscriptRecord,
+): string | undefined {
+ if (record.type !== 'user') return undefined;
+
+ const parts = Array.isArray(record.message?.parts)
+ ? record.message.parts
+ : [];
+ const hasFinalHookContextPart =
+ parts.length > 1 && isHookContextPart(parts[parts.length - 1]);
+ const payload = isRecord(record.systemPayload)
+ ? record.systemPayload
+ : undefined;
+ if (
+ payload &&
+ typeof payload.displayText === 'string' &&
+ (typeof payload.hookContext === 'string' || hasFinalHookContextPart)
+ ) {
+ return payload.displayText;
+ }
+
+ if (parts.length === 0) return undefined;
+ const visibleParts =
+ payload === undefined && hasFinalHookContextPart
+ ? parts.slice(0, -1)
+ : parts;
+ return visibleParts
+ .map((part) =>
+ isRecord(part) && typeof part.text === 'string' ? part.text : '',
+ )
+ .join('');
+}
diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.test.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.test.tsx
index 65ac7006ec0..8b2a5734631 100644
--- a/packages/webui/src/components/ChatViewer/ChatViewer.test.tsx
+++ b/packages/webui/src/components/ChatViewer/ChatViewer.test.tsx
@@ -39,3 +39,29 @@ describe('ChatViewer tool routing', () => {
expect(html).toContain('ReadToolCall');
});
});
+
+describe('ChatViewer user transcript projection', () => {
+ it('renders released single-field display provenance instead of model-facing hook context', () => {
+ const taggedContext =
+ '\nhook-only context\n';
+ const message: ChatMessageData = {
+ uuid: 'user-1',
+ timestamp: '2026-03-22T16:48:35.000Z',
+ type: 'user',
+ message: {
+ role: 'user',
+ parts: [{ text: 'expanded model prompt' }, { text: taggedContext }],
+ },
+ systemPayload: {
+ displayText: 'raw @file prompt',
+ },
+ };
+
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain('raw @file prompt');
+ expect(html).not.toContain('expanded model prompt');
+ expect(html).not.toContain('hook-only context');
+ expect(html).not.toContain('qwen:user-prompt-submit-context');
+ });
+});
diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.tsx
index a753903fcb0..60cfc7502c5 100644
--- a/packages/webui/src/components/ChatViewer/ChatViewer.tsx
+++ b/packages/webui/src/components/ChatViewer/ChatViewer.tsx
@@ -19,6 +19,7 @@ import {
getToolCallComponent,
} from '../toolcalls/index.js';
import type { ToolCallData as BaseToolCallData } from '../toolcalls/index.js';
+import { getUserTranscriptDisplayText } from '../../adapters/userTranscriptDisplay.js';
import './ChatViewer.css';
/**
@@ -62,6 +63,7 @@ export interface ChatMessageData {
model?: string; // for assistant messages
// Tool call data
toolCall?: ToolCallData;
+ systemPayload?: unknown;
// Additional Claude format fields
cwd?: string;
gitBranch?: string;
@@ -274,7 +276,8 @@ export const ChatViewer = forwardRef(
);
}
- const content = extractContent(msg.message);
+ const content =
+ getUserTranscriptDisplayText(msg) ?? extractContent(msg.message);
const timestamp = parseTimestamp(msg.timestamp);
// Skip empty messages (but not tool calls)