diff --git a/docs/design/2026-07-28-user-prompt-submit-context-provenance.md b/docs/design/2026-07-28-user-prompt-submit-context-provenance.md new file mode 100644 index 00000000000..c1e299874db --- /dev/null +++ b/docs/design/2026-07-28-user-prompt-submit-context-provenance.md @@ -0,0 +1,86 @@ +# UserPromptSubmit hook context provenance + +Issue: https://github.com/QwenLM/qwen-code/issues/7940 + +## Problem + +`UserPromptSubmit` hooks can return `additionalContext`, which the client +appends to the outgoing request as a bare text part. Because +`recordUserMessage` persists the augmented request, the injected text lands in +the user record's `message.parts` indistinguishable from user-authored text. + +Consequences: + +- **Resume**: the UI projection concatenates all text parts, so resumed + sessions display hook-injected context as if the user typed it. +- **Offline analysis / downstream consumers**: the JSONL transcript cannot + separate user text from injection; consumers resort to fragile custom + marker-stripping heuristics. +- **Telemetry & auto-memory recall**: both consumed `partToString(request)` + after injection, polluting the prompt attribute and the recall query. + +The live TUI is unaffected (it builds its history item from the pre-hook +input), which is exactly the asymmetry that made the polluted transcript easy +to miss. + +## Design + +Isomorphic to two existing patterns: `SessionStart` context is injected as a +tagged block into the system instruction, and mid-turn/notification records +separate the model-bound `message` from a `systemPayload.displayText` +projection. + +### Write path + +1. **Tagged injection** (`client.ts`): the sanitized `additionalContext` is + appended as its own part wrapped in + `...`. + `getAdditionalContext()` escapes `<`/`>` in hook output, so the wrapper + cannot be closed or forged from inside. User-authored text is never + rewritten or escaped. `promptText` must be declared before the injection + assignment that captures it into `preInjectionPromptText` (avoids a TDZ + if the surrounding Goal try/catch is later reshuffled). +2. **Display provenance** (`chatRecordingService.ts`): `recordUserMessage` + accepts an optional `UserPromptRecordPayload { displayText? }` stored as + `systemPayload`. `message` keeps the exact model-bound Content — resume + must replay what the model actually saw — while `displayText` preserves + the pre-injection user projection. Hook-injected text remains in the + tagged `message.parts` entry (machine-parseable). The payload is only + written when a hook actually injected context. +3. **Telemetry & recall** (`client.ts`): `addUserPromptAttributes` and + `MemoryManager.recall` use the pre-injection prompt text when injection + occurred. + +### Read path (resume projection) + +`resumeHistoryUtils` projects plain user records through a three-shape +fallback: + +- (a) new records: prefer `systemPayload.displayText`; +- (b) tag-only records (no payload): drop a trailing part that is, in its + entirety, a tagged block — whole-part strict match only, so user prose that + merely contains the tag is never stripped. A sole part matching the tag + shape is also kept (injection always appends after the user's own part(s), + so a single-part record can only be user-authored); +- (c) legacy bare-injected records: unchanged concatenation. + +The `@`-command resume branch still prefers `AtCommandRecordPayload.userText` +when present; only the absent-`userText` fallback goes through +`extractUserRecordDisplayText`, so a trailing tagged part does not override +the `@`-command display text. + +## Scope notes + +- Focused on the interactive `UserPromptSubmit` path. The ACP session path + already records the pre-injection prompt text, so it only needed the same + tag wrapping on its model-bound injection (included here). Subagent context + injection (`SubagentStart` via `contextState`) needs its own investigation + and is a follow-up. +- Other transcript consumers (desktop, web UI) can adopt `displayText` in + follow-ups; until then they see the tagged shape, which is at least + mechanically identifiable. + +ACP/export/daemon consumers that go through `transcript-replay`'s +`projectUserRecord` also prefer `displayText` and strip a trailing tagged +part for subtype-less user records (same three-shape fallback as the TUI +resume path). diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index ebc285911f8..ad83ba2c9c3 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -557,6 +557,8 @@ Sequential UserPromptSubmit hooks can append `additionalContext` to `prompt`; `s - `reason`: human-readable explanation for the decision - `hookSpecificOutput.additionalContext`: additional context to append to the prompt (optional) +When sent to the model, injected `additionalContext` is appended as its own message part wrapped in a reserved `...` tag, so it stays distinguishable from user-authored text in model history and session transcripts. Angle brackets in hook output are escaped before wrapping, so hook content cannot close or forge the tag. The session transcript also records the user's original prompt text separately; the interactive TUI and the ACP/export transcript-replay path display that original text rather than the injected context. + **Note**: Since UserPromptSubmitOutput extends HookOutput, all standard fields are available but only additionalContext in hookSpecificOutput is specifically defined for this event. **Example Output**: diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index bc29c7c6438..20ca9d6c063 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -193,6 +193,135 @@ describe('createTranscriptReplayMachine', () => { ]); }); + describe('UserPromptSubmit hook context provenance', () => { + 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). + const projected = updates( + createTranscriptReplayMachine(), + record('user-1', 'user', { + message: { + role: 'user', + parts: [ + { + inlineData: { + data: 'abc123', + mimeType: 'image/png', + }, + }, + { text: 'my prompt' }, + { text: 'expanded extra' }, + { text: tagged }, + ], + }, + systemPayload: { + displayText: 'my prompt', + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'image', + data: 'abc123', + mimeType: 'image/png', + }, + }, + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'my prompt' }, + }, + ]); + expect(projected).toHaveLength(2); + }); + + it('appends displayText after images when the record has no text part to replace', () => { + // Exercises the !replaced fallback: after stripping the trailing tagged + // block, only the image remains, so displayText is appended. + const projected = updates( + createTranscriptReplayMachine(), + record('user-img-only', 'user', { + message: { + role: 'user', + parts: [ + { + inlineData: { + data: 'abc', + mimeType: 'image/png', + }, + }, + { text: tagged }, + ], + }, + systemPayload: { + displayText: 'my image prompt', + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'image', + data: 'abc', + mimeType: 'image/png', + }, + }, + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'my image prompt' }, + }, + ]); + expect(projected).toHaveLength(2); + }); + + it('strips a trailing whole-part tagged block when displayText is absent', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('user-2', 'user', { + message: { + role: 'user', + parts: [{ text: 'my prompt' }, { text: tagged }], + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'my prompt' }, + }, + ]); + expect(projected).toHaveLength(1); + }); + + it('keeps a sole part that matches the tag shape', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('user-3', 'user', { + message: { + role: 'user', + parts: [{ text: tagged }], + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: tagged }, + }, + ]); + }); + }); + it('projects ordered message parts with source metadata', () => { const machine = createTranscriptReplayMachine(); const projected = updates( diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index d0cb810091a..ecef60c951b 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -21,6 +21,10 @@ import { projectGoalStateToLegacy, type GoalSnapshotV2, } from '@qwen-code/qwen-code-core/goalWire'; +// Narrow path — the helper is Node-free. Importing the core package barrel +// here would pull the whole Node-bound core graph into the browser +// transcript bundle (sdk-typescript daemon/transcript). +import { stripTrailingUserPromptSubmitContextPart } from '@qwen-code/qwen-code-core/userPromptSubmitContext'; export const MISSING_TRANSCRIPT_TOOL_RESULT_MESSAGE = 'Tool result missing from saved history; the previous run likely ended ' + @@ -513,10 +517,101 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { return; } if (record.subtype !== 'mid_turn_user_message') return; + } else if (!record.subtype) { + // Plain user records — including UserPromptSubmit-augmented ones — + // prefer the recorded display projection, then strip a trailing + // whole-part tagged hook-context block. Matches resumeHistoryUtils. + // Always go through projectMessageParts so multimodal inlineData + // (images) survives even when displayText replaces the text parts. + const payload = isObjectRecord(record.systemPayload) + ? record.systemPayload + : undefined; + const displayText = + payload && typeof payload['displayText'] === 'string' + ? payload['displayText'] + : undefined; + yield* this.projectMessageParts( + displayText + ? this.withUserPromptDisplayText(record, displayText) + : this.withoutTrailingUserPromptSubmitContext(record), + 'user', + emit, + meta, + ); + return; } yield* this.projectMessageParts(record, 'user', emit, meta); } + /** + * Drops a trailing message part that is entirely a tagged UserPromptSubmit + * context block. Injection always appends after the user's own part(s), so + * a sole matching part is treated as user-authored and kept. + */ + private withoutTrailingUserPromptSubmitContext( + record: TranscriptRecordInput, + ): TranscriptRecordInput { + const parts = record.message?.parts; + if (!Array.isArray(parts)) { + return record; + } + const nextParts = stripTrailingUserPromptSubmitContextPart(parts); + if (nextParts === parts) { + return record; + } + return { + ...record, + message: { + ...record.message, + parts: [...nextParts], + }, + }; + } + + /** + * Rebuilds a plain user record for display: strip trailing tagged hook + * context, then replace every text part with a single `displayText` part at + * the first text position so images keep their relative order. + */ + private withUserPromptDisplayText( + record: TranscriptRecordInput, + displayText: string, + ): TranscriptRecordInput { + const stripped = this.withoutTrailingUserPromptSubmitContext(record); + const parts = stripped.message?.parts; + if (!Array.isArray(parts) || parts.length === 0) { + return { + ...stripped, + message: { + ...stripped.message, + parts: [{ text: displayText }], + }, + }; + } + let replaced = false; + const nextParts: unknown[] = []; + for (const part of parts) { + if (isObjectRecord(part) && typeof part['text'] === 'string') { + if (!replaced) { + nextParts.push({ text: displayText }); + replaced = true; + } + continue; + } + nextParts.push(part); + } + if (!replaced) { + nextParts.push({ text: displayText }); + } + return { + ...stripped, + message: { + ...stripped.message, + parts: nextParts, + }, + }; + } + private *projectAssistantRecord( record: TranscriptRecordInput, emit: (update: SessionUpdate) => TranscriptReplayEmission, diff --git a/packages/acp-bridge/vitest.config.ts b/packages/acp-bridge/vitest.config.ts index 48e2356ea40..731a7b5b037 100644 --- a/packages/acp-bridge/vitest.config.ts +++ b/packages/acp-bridge/vitest.config.ts @@ -18,6 +18,10 @@ export default defineConfig({ __dirname, '../core/src/utils/transcript-records.ts', ), + '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( + __dirname, + '../core/src/hooks/user-prompt-submit-context.ts', + ), }, }, test: { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index aefd1b475bb..992db0643c6 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -13749,6 +13749,46 @@ describe('Session', () => { expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); expect(result.stopReason).toBe('end_turn'); }); + + it('wraps additionalContext in the reserved tag before sending', async () => { + const messageBus = { + request: vi.fn().mockResolvedValue({ + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false); + mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); + + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'response' }] } }], + }, + }, + ]), + ); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const sent = firstSentMessage(); + expect(textParts(sent)[0]).toBe('hello'); + expect( + core.isUserPromptSubmitContextPartText(textParts(sent).at(-1)!), + ).toBe(true); + expect(textParts(sent).at(-1)).toContain('extra hook context'); + }); }); describe('Stop hook', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 71194357f1b..0abb734eb33 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -86,6 +86,7 @@ import { NotificationType, persistPermissionOutcome, createHookOutput, + wrapUserPromptSubmitContext, generateToolUseId, MessageBusType, MessageDisplayDispatcher, @@ -2852,10 +2853,15 @@ export class Session implements SessionContext { return { stopReason: 'end_turn' }; } - // Add additional context from hooks to the request + // Add additional context from hooks to the request, wrapped in + // the reserved tag so it stays distinguishable from + // user-authored text (same shape as the interactive path). const additionalContext = hookOutput?.getAdditionalContext(); if (additionalContext) { - parts = [...parts, { text: additionalContext }]; + parts = [ + ...parts, + { text: wrapUserPromptSubmitContext(additionalContext) }, + ]; } } diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 1e76ed08029..a463372447f 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -118,6 +118,147 @@ describe('resumeHistoryUtils', () => { expect(userItem.text).toBe('post-gap message'); }); + describe('UserPromptSubmit hook context provenance', () => { + const tagged = + '\ninjected hook context\n'; + + const buildUserItems = (record: Record) => { + const conversation = { + messages: [record], + } as unknown as ConversationRecord; + const session: ResumedSessionData = { + conversation, + } as ResumedSessionData; + return buildResumedHistoryItems(session, makeConfig({}), 1_000); + }; + + it('prefers recorded displayText over the augmented parts', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: 'my prompt' }, { text: tagged }] }, + systemPayload: { + displayText: 'my prompt', + }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); + }); + + it('prefers displayText over the tag-strip fallback', () => { + // Fixture where the two branches disagree: without displayText the + // tag-strip path would expose the middle "expanded extra" part. + const items = buildUserItems({ + type: 'user', + message: { + parts: [ + { text: 'my prompt' }, + { text: 'expanded extra' }, + { text: tagged }, + ], + }, + systemPayload: { + displayText: 'my prompt', + }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); + }); + + it('strips a trailing whole-part tagged block when no displayText is recorded', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: 'my prompt' }, { text: tagged }] }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); + }); + + it('keeps user-authored text that merely contains the tag', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: `quote: ${tagged} end` }] }, + }); + expect(items).toEqual([ + { id: 1_001, type: 'user', text: `quote: ${tagged} end` }, + ]); + }); + + it('keeps a sole part that matches the tag shape (user-authored)', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: tagged }] }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: tagged }]); + }); + + it('falls back to raw concatenation for legacy bare-injected records', () => { + const items = buildUserItems({ + type: 'user', + message: { + parts: [{ text: 'my prompt' }, { text: 'bare injected context' }], + }, + }); + expect(items).toEqual([ + { id: 1_001, type: 'user', text: 'my prompt\nbare injected context' }, + ]); + }); + + it('prefers at_command userText even when the paired user record has a trailing tagged part', () => { + const conversation = { + messages: [ + { + type: 'system', + subtype: 'at_command', + systemPayload: { + userText: '@file.ts summarize this', + filesRead: ['/tmp/file.ts'], + status: 'success', + }, + }, + { + type: 'user', + message: { + parts: [{ text: 'expanded model prompt' }, { text: tagged }], + }, + }, + ], + } as unknown as ConversationRecord; + const items = buildResumedHistoryItems( + { conversation } as ResumedSessionData, + makeConfig({}), + 1_000, + ); + const userItem = items.find((i) => i.type === 'user') as { text: string }; + expect(userItem.text).toBe('@file.ts summarize this'); + expect(userItem.text).not.toContain('qwen:user-prompt-submit-context'); + }); + + it('strips a trailing tagged part when at_command userText is absent', () => { + const conversation = { + messages: [ + { + type: 'system', + subtype: 'at_command', + systemPayload: { + filesRead: ['/tmp/file.ts'], + status: 'success', + }, + }, + { + type: 'user', + message: { + parts: [{ text: 'my prompt' }, { text: tagged }], + }, + }, + ], + } as unknown as ConversationRecord; + const items = buildResumedHistoryItems( + { conversation } as ResumedSessionData, + makeConfig({}), + 1_000, + ); + const userItem = items.find((i) => i.type === 'user') as { text: string }; + expect(userItem.text).toBe('my prompt'); + }); + }); + it('converts conversation into history items with incremental ids', () => { const conversation = { messages: [ diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 394141ecaaf..d7122693624 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -15,8 +15,12 @@ import type { SlashCommandRecordPayload, AtCommandRecordPayload, HistoryGap, + UserPromptRecordPayload, +} from '@qwen-code/qwen-code-core'; +import { + getToolResponseDisplayText, + stripTrailingUserPromptSubmitContextPart, } from '@qwen-code/qwen-code-core'; -import { getToolResponseDisplayText } from '@qwen-code/qwen-code-core'; import type { HistoryItem, HistoryItemInfo, @@ -31,6 +35,28 @@ import { indexGapsByChild, } from './history-gap-notice.js'; +/** + * Projects a plain user record to its display text. + * + * Prefers the `displayText` recorded when a UserPromptSubmit hook augmented + * the model-bound parts. For records that carry the reserved tag but no + * payload (written by other/newer writers), drops a trailing part that is + * entirely a tagged hook-context block. Legacy records with bare injected + * text fall back to the raw part concatenation. + */ +function extractUserRecordDisplayText( + record: ConversationRecord['messages'][number], +): string { + const payload = record.systemPayload as 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). */ @@ -356,9 +382,7 @@ function convertToHistoryItems( } const payload = pendingAtCommands.shift()!; - const text = - payload.userText || - extractTextFromParts(record.message?.parts as Part[]); + const text = payload.userText || extractUserRecordDisplayText(record); if (text) { items.push({ type: 'user', text }); } @@ -381,7 +405,7 @@ function convertToHistoryItems( currentToolGroup = []; } - const text = extractTextFromParts(record.message?.parts as Part[]); + const text = extractUserRecordDisplayText(record); if (text) { items.push({ type: 'user', text }); } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d6cfd6cefd6..316f6799569 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ __dirname, '../core/src/utils/transcript-records.ts', ), + '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( + __dirname, + '../core/src/hooks/user-prompt-submit-context.ts', + ), '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'), // cli's daemon-status-provider.test.ts imports `FakeAgent` / // `makeChannel` from acp-bridge's package-private diff --git a/packages/core/package.json b/packages/core/package.json index dffe771c14e..8f9e9a40e11 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,10 @@ "types": "./dist/src/goals/goal-wire.d.ts", "import": "./dist/src/goals/goal-wire.js" }, + "./userPromptSubmitContext": { + "types": "./dist/src/hooks/user-prompt-submit-context.d.ts", + "import": "./dist/src/hooks/user-prompt-submit-context.js" + }, "./package.json": "./package.json", "./dist/*": "./dist/*", "./src/*": "./src/*" diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e219c8d2cbc..2704db6e4ae 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -348,6 +348,8 @@ vi.mock('../telemetry/loggers.js', () => ({ logLoopDetectionDisabled: vi.fn(), })); +import * as telemetryIndex from '../telemetry/index.js'; + const { mockClientDebugLogger } = vi.hoisted(() => ({ mockClientDebugLogger: { isEnabled: vi.fn().mockReturnValue(false), @@ -9657,6 +9659,166 @@ Other open files: }); }); + it('wraps injected additionalContext in the reserved tag and records display provenance', async () => { + const mockMessageBus = { + request: vi.fn().mockResolvedValue({ + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + 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', + ); + const recordUserMessage = vi.fn(); + vi.mocked(mockConfig.getChatRecordingService).mockReturnValue({ + recordUserMessage, + recordCronPrompt: vi.fn(), + recordAttributionSnapshot: vi.fn(), + } as unknown as ReturnType); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: GeminiEventType.Content, value: 'ok' }; + })(), + ); + + await fromAsync( + client.sendMessageStream( + [{ text: 'my prompt' }], + new AbortController().signal, + 'prompt-hook-context-tag', + ), + ); + + const taggedContext = + '\nextra hook context\n'; + + // The model-bound request keeps the user prompt intact and carries + // the injected context inside the reserved tag. + const requestText = getLastTurnRequestText(); + expect(requestText).toContain('my prompt'); + expect(requestText).toContain(taggedContext); + + // The recorded message is the exact model-bound request, with the + // user-authored projection preserved separately. + expect(recordUserMessage).toHaveBeenCalledWith( + [{ text: 'my prompt' }, { text: taggedContext }], + undefined, + { displayText: 'my prompt' }, + ); + }); + + it('uses the pre-injection prompt for managed auto-memory recall', async () => { + const mockMessageBus = { + request: vi.fn().mockResolvedValue({ + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + 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', + ); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: GeminiEventType.Content, value: 'ok' }; + })(), + ); + + await fromAsync( + client.sendMessageStream( + [{ text: 'my prompt' }], + new AbortController().signal, + 'prompt-hook-context-recall', + ), + ); + + expect(mockMemoryManager.recall).toHaveBeenCalledWith( + '/test/project/root', + 'my prompt', + expect.any(Object), + ); + }); + + it('uses the pre-injection prompt for telemetry user-prompt attributes', async () => { + const mockMessageBus = { + request: vi.fn().mockResolvedValue({ + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + 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', + ); + Object.assign(mockConfig, { + getTelemetryIncludeSensitiveSpanAttributes: vi + .fn() + .mockReturnValue(true), + }); + const startSpy = vi + .spyOn(telemetryIndex, 'startInteractionSpan') + .mockImplementation(() => {}); + const spanSpy = vi + .spyOn(telemetryIndex, 'getActiveInteractionSpan') + .mockReturnValue({} as never); + const addSpy = vi + .spyOn(telemetryIndex, 'addUserPromptAttributes') + .mockImplementation(() => {}); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: GeminiEventType.Content, value: 'ok' }; + })(), + ); + + try { + await fromAsync( + client.sendMessageStream( + [{ text: 'my prompt' }], + new AbortController().signal, + 'prompt-hook-context-telemetry', + ), + ); + + expect(addSpy).toHaveBeenCalledWith( + mockConfig, + expect.anything(), + 'my prompt', + ); + const promptArg = addSpy.mock.calls[0]?.[2] as string; + expect(promptArg).not.toContain('extra hook context'); + expect(promptArg).not.toContain('qwen:user-prompt-submit-context'); + } finally { + startSpy.mockRestore(); + spanSpy.mockRestore(); + addSpy.mockRestore(); + } + }); + it.each([ { name: 'empty UserQuery value', diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index f2af5cd93d4..55ced685ae3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -50,6 +50,7 @@ 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'; @@ -2270,6 +2271,11 @@ 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) let hooksEnabled: boolean; let messageBus: ReturnType; @@ -2351,11 +2357,21 @@ export class GeminiClient { return new Turn(this.getChat(), prompt_id); } - // Add additional context from hooks to the request + // Add additional context from hooks to the request. The context is + // appended as its own part, wrapped in a reserved tag so it stays + // distinguishable from user-authored text in model history, resume, + // and offline transcript analysis. `getAdditionalContext()` escapes + // `<`/`>`, so hook output cannot forge the closing tag. + // `promptText` is declared above this block so assignment here cannot + // hit a TDZ if the surrounding Goal try/catch is later reshuffled. const additionalContext = hookOutput?.getAdditionalContext(); if (additionalContext) { const requestArray = Array.isArray(request) ? request : [request]; - request = [...requestArray, { text: additionalContext }]; + request = [ + ...requestArray, + { text: wrapUserPromptSubmitContext(additionalContext) }, + ]; + preInjectionPromptText = promptText; } } } catch (error) { @@ -2502,7 +2518,7 @@ export class GeminiClient { addUserPromptAttributes( this.config, interactionSpan, - partToString(request), + preInjectionPromptText ?? partToString(request), ); } } @@ -2557,12 +2573,16 @@ export class GeminiClient { } const promise = this.config .getMemoryManager() - .recall(this.config.getProjectRoot(), partToString(request), { - config: this.config, - excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, - recentTools: [...this.recentCompletedToolNames], - abortSignal: controller.signal, - }) + .recall( + this.config.getProjectRoot(), + preInjectionPromptText ?? partToString(request), + { + config: this.config, + excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, + recentTools: [...this.recentCompletedToolNames], + abortSignal: controller.signal, + }, + ) .catch((error: unknown) => { // Abort sources are now numerous (caller signal, new UserQuery, // cleanup paths, safety-net timeout). Keep a debug trace so @@ -2627,9 +2647,17 @@ export class GeminiClient { goalPermit, ); } else { - this.config - .getChatRecordingService() - ?.recordUserMessage(request, goalPermit); + // 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); + } } } diff --git a/packages/core/src/hooks/user-prompt-submit-context.test.ts b/packages/core/src/hooks/user-prompt-submit-context.test.ts new file mode 100644 index 00000000000..da826eedabc --- /dev/null +++ b/packages/core/src/hooks/user-prompt-submit-context.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +describe('wrapUserPromptSubmitContext', () => { + it('wraps context between the open and close tags', () => { + expect(wrapUserPromptSubmitContext('extra context')).toBe( + `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}\nextra context\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG}`, + ); + }); + + it('produces text recognized by isUserPromptSubmitContextPartText', () => { + expect( + isUserPromptSubmitContextPartText( + wrapUserPromptSubmitContext('multi\nline\ncontext'), + ), + ).toBe(true); + }); +}); + +describe('stripTrailingUserPromptSubmitContextPart', () => { + it('drops a trailing whole-part tagged block when other parts exist', () => { + const tagged = wrapUserPromptSubmitContext('ctx'); + expect( + stripTrailingUserPromptSubmitContextPart([ + { text: 'my prompt' }, + { text: tagged }, + ]), + ).toEqual([{ text: 'my prompt' }]); + }); + + it('keeps a sole part that matches the tag shape', () => { + const tagged = wrapUserPromptSubmitContext('ctx'); + const parts = [{ text: tagged }]; + expect(stripTrailingUserPromptSubmitContextPart(parts)).toBe(parts); + }); + + it('keeps parts when the trailing part is not a whole tagged block', () => { + const parts = [ + { text: 'my prompt' }, + { text: `quote: ${wrapUserPromptSubmitContext('ctx')}` }, + ]; + expect(stripTrailingUserPromptSubmitContextPart(parts)).toBe(parts); + }); +}); + +describe('isUserPromptSubmitContextPartText', () => { + it('accepts a wrapped block with surrounding whitespace', () => { + expect( + isUserPromptSubmitContextPartText( + `\n ${wrapUserPromptSubmitContext('ctx')}\n`, + ), + ).toBe(true); + }); + + it('rejects text with user prose before the tag', () => { + expect( + isUserPromptSubmitContextPartText( + `my own text ${wrapUserPromptSubmitContext('ctx')}`, + ), + ).toBe(false); + }); + + it('rejects text with user prose after the tag', () => { + expect( + isUserPromptSubmitContextPartText( + `${wrapUserPromptSubmitContext('ctx')} trailing text`, + ), + ).toBe(false); + }); + + it('rejects an unterminated open tag', () => { + expect( + isUserPromptSubmitContextPartText( + `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}\nctx`, + ), + ).toBe(false); + }); + + it('rejects a lone close tag', () => { + expect( + isUserPromptSubmitContextPartText(USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG), + ).toBe(false); + }); + + it('rejects empty text', () => { + expect(isUserPromptSubmitContextPartText('')).toBe(false); + }); +}); diff --git a/packages/core/src/hooks/user-prompt-submit-context.ts b/packages/core/src/hooks/user-prompt-submit-context.ts new file mode 100644 index 00000000000..8ae7eafc927 --- /dev/null +++ b/packages/core/src/hooks/user-prompt-submit-context.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Qwen Code + * 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 = + ''; + +/** + * 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. + */ +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 + ); +} + +/** + * Drops a trailing 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. Returns the same + * array reference when nothing is stripped. + */ +export function stripTrailingUserPromptSubmitContextPart( + parts: readonly T[], +): readonly T[] { + if (parts.length <= 1) { + return parts; + } + const last = parts[parts.length - 1] as { text?: unknown } | undefined; + if ( + !last || + typeof last.text !== 'string' || + !isUserPromptSubmitContextPartText(last.text) + ) { + return parts; + } + return parts.slice(0, -1); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ddaab28a003..126108aa57a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -628,6 +628,13 @@ export { } from './hooks/stopHookCap.js'; export { type StopFailureErrorType } from './hooks/types.js'; 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'; // ============================================================================ // Goals (/goal command runtime) diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 4f93ba4dda1..016765dd9c8 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -159,6 +159,32 @@ 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' }, + { + text: '\nextra\n', + }, + ]; + chatRecordingService.recordUserMessage(taggedParts, undefined, { + displayText: 'my prompt', + }); + 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', + }); + expect(plain.systemPayload).toBeUndefined(); + }); + it('blocks later turns after a generic durable write failure', async () => { const failure = new Error('disk full'); vi.mocked(mockLease.appendJsonLine).mockRejectedValueOnce(failure); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 706fbe8d626..6699e448e1b 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -349,6 +349,7 @@ export interface ChatRecord { | ParentSessionRecordPayload | SessionSourceRecordPayload | NotificationRecordPayload + | UserPromptRecordPayload | RewindRecordPayload | AgentBootstrapRecordPayload | FileHistorySnapshotRecordPayload @@ -391,6 +392,19 @@ 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?: { @@ -1276,6 +1290,7 @@ export class ChatRecordingService { recordUserMessage( message: PartListUnion, goalContext?: GoalTurnPermit, + payload?: UserPromptRecordPayload, ): void { try { this.turnParentUuids.push(this.lastRecordUuid); @@ -1283,6 +1298,7 @@ export class ChatRecordingService { ...this.createBaseRecord('user'), ...(goalContext ? { goalContext: copyGoalContext(goalContext) } : {}), message: createUserContent(message), + ...(payload ? { systemPayload: payload } : {}), }; this.appendRecord(record); } catch (error) {