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)