Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1b83ebb
fix(core): separate hook context from transcript display
destire-mio Jul 28, 2026
a5d71df
Merge upstream main into codex/fix-7940-user-prompt-context
destire-mio Jul 29, 2026
eca5832
Merge branch 'main' into codex/fix-7940-user-prompt-context
wenshao Jul 29, 2026
10283d9
test(ci): gate desktop transcript projection
destire-mio Jul 29, 2026
0f86770
revert: keep desktop CI scope unchanged
destire-mio Jul 29, 2026
b9eaf55
Merge branch 'main' into codex/fix-7940-user-prompt-context
wenshao Jul 29, 2026
762ba8b
test: cover transcript display fallbacks
destire-mio Jul 29, 2026
ee7ee23
fix(transcript): address review feedback
destire-mio Jul 30, 2026
3879b70
chore: merge origin/main resolving UserPromptSubmit transcript-displa…
qwen-code-dev-bot Jul 31, 2026
00d06c8
Merge branch 'main' into codex/fix-7940-user-prompt-context
wenshao Jul 31, 2026
3052b25
fix(transcript): reconcile post-merge provenance paths
destire-mio Jul 31, 2026
f45208d
chore: merge origin/main into codex/fix-7940-user-prompt-context
qwen-code-dev-bot Aug 1, 2026
eab31c8
fix(webui): preserve legacy transcript concatenation
destire-mio Aug 1, 2026
5e80f24
Merge branch 'main' into codex/fix-7940-user-prompt-context
wenshao Aug 1, 2026
aa66dc1
Merge branch 'main' into codex/fix-7940-user-prompt-context
wenshao Aug 1, 2026
891ee3c
test(transcript): cover projection consumers
destire-mio Aug 2, 2026
8920e28
fix(transcript): consolidate hook context projection
destire-mio Aug 2, 2026
d808b4f
fix(transcript): support single-field display provenance
destire-mio Aug 2, 2026
ad0d857
fix(transcript): strip hook context with invalid metadata
destire-mio Aug 2, 2026
f90c51d
test(acp): cover empty replay display text
destire-mio Aug 3, 2026
a4be4f6
Merge branch 'main' into codex/fix-7940-user-prompt-context
wenshao Aug 3, 2026
d66130b
Merge origin/main into codex/fix-7940-user-prompt-context
destire-mio Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,54 @@ 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
<qwen:user-prompt-submit-context>
sanitized hook context
</qwen:user-prompt-submit-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 must require `systemPayload.hookContext` to be a
string before treating `displayText` as this user-prompt projection.
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. Legacy bare-context records without the two-field payload 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 a complete tagged context only when it is
the final part after at least one other 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"
Expand Down
118 changes: 118 additions & 0 deletions packages/acp-bridge/src/transcript-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,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: [
'<qwen:user-prompt-submit-context>',
'hook-only context',
'</qwen:user-prompt-submit-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: [
'<qwen:user-prompt-submit-context>',
'hook-only context',
'</qwen:user-prompt-submit-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('projects ordered message parts with source metadata', () => {
const machine = createTranscriptReplayMachine();
const projected = updates(
Expand Down
74 changes: 61 additions & 13 deletions packages/acp-bridge/src/transcript-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import type {
ToolCallLocation,
ToolKind,
} from '@agentclientprotocol/sdk';
import type {
TranscriptProjectionDiagnostic,
TranscriptRecordInput,
TranscriptReplayGapInput,
import {
projectUserTranscriptForDisplay,
type TranscriptProjectionDiagnostic,
type TranscriptRecordInput,
type TranscriptReplayGapInput,
} from '@qwen-code/qwen-code-core/transcriptRecords';
import {
parseGoalSnapshotV2,
Expand Down Expand Up @@ -157,6 +158,28 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
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 {
Expand Down Expand Up @@ -473,25 +496,25 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
emit: (update: SessionUpdate) => TranscriptReplayEmission,
meta: UpdateMetaOptions,
): Iterable<TranscriptReplayEmission> {
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',
Expand All @@ -514,7 +537,31 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
}
if (record.subtype !== 'mid_turn_user_message') return;
}
yield* this.projectMessageParts(record, 'user', emit, meta);

const projection = projectUserTranscriptForDisplay(record);
if (projection.displayText !== undefined) {
yield* this.projectMessageParts(
record,
'user',
emit,
meta,
undefined,
replaceTextPartsForDisplay(
record.message?.parts,
projection.displayText,
),
);
return;
}

yield* this.projectMessageParts(
record,
'user',
emit,
meta,
undefined,
projection.parts,
);
}

private *projectAssistantRecord(
Expand Down Expand Up @@ -549,8 +596,9 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
emit: (update: SessionUpdate) => TranscriptReplayEmission,
meta: UpdateMetaOptions,
beforeToolCall?: () => SessionUpdate | undefined,
partsOverride?: readonly unknown[],
): Iterable<TranscriptReplayEmission> {
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];
Expand Down
Loading
Loading