Skip to content

fix(core): separate hook context from transcript display - #7948

Merged
wenshao merged 22 commits into
QwenLM:mainfrom
destire-mio:codex/fix-7940-user-prompt-context
Aug 8, 2026
Merged

fix(core): separate hook context from transcript display#7948
wenshao merged 22 commits into
QwenLM:mainfrom
destire-mio:codex/fix-7940-user-prompt-context

Conversation

@destire-mio

@destire-mio destire-mio commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What this PR does

This PR separates model-facing UserPromptSubmit hook context from user-visible transcript text. It wraps the sanitized hook context in a dedicated <qwen:user-prompt-submit-context> part, persists clean displayText and hookContext provenance while retaining the exact model-bound message parts, and makes transcript consumers use the clean projection across CLI resume, insight generation, session references, goal evidence, conversation branches, ACP replay, WebUI, VS Code, and Desktop. Telemetry and automatic-memory recall now use the pre-hook user prompt instead of the previous post-hook prompt, so UserPromptSubmit-added context is excluded.

Why it's needed

UserPromptSubmit additionalContext was appended to the user request and that already-augmented request was then recorded. Consumers that concatenated every user message part therefore displayed hook-injected context as if the user had typed it when resuming a session or analyzing JSONL. Fixing only one UI would leave the persisted provenance ambiguous and duplicate brittle stripping logic, while removing the context from the recorded message would make model-history resume inaccurate. This change keeps the model and display views separate.

Reviewer Test Plan

How to verify

  1. Configure a UserPromptSubmit hook that returns additionalContext with the value "hook-only context".
  2. Submit the user prompt "user prompt".
  3. Inspect the recorded user JSONL entry. Its message parts should retain the exact model-facing user prompt plus one tagged hook-context part, while systemPayload.displayText should be "user prompt" and systemPayload.hookContext should be "hook-only context".
  4. Resume the session through the CLI or replay it through ACP/WebUI/VS Code/Desktop. The visible user entry should contain only "user prompt"; the tagged hook context must remain available to model history and offline consumers.
  5. Confirm the compatibility cases: UserPromptSubmit records require paired displayText and hookContext provenance and treat displayText as authoritative even when empty; notification, cron, and mid-turn display labels do not replace their model-bound text in general transcript consumers; tag-only records remove only a complete exact final context part; legacy untagged records retain their existing text; non-text parts such as images remain available.
  6. Run the focused regression suites and repository checks:
npm run build
npm run typecheck
npm run bundle
npm run test:integration:no-ak:sandbox:none

cd packages/core
npx vitest run src/core/client.test.ts src/core/prompts.test.ts src/services/chatRecordingService.test.ts src/utils/transcript-records.test.ts src/hooks/user-prompt-submit-context.test.ts src/goals/goal-evidence.test.ts src/utils/conversation-branches.test.ts src/services/session-reference-service.test.ts

cd ../cli
npx vitest run src/services/insight/generators/DataProcessor.test.ts src/ui/utils/resumeHistoryUtils.test.ts src/acp-integration/session/history-replayer.test.ts

cd ../acp-bridge
npx vitest run src/transcript-replay.test.ts

cd ../webui
npx vitest run src/adapters/JSONLAdapter.test.ts src/components/ChatViewer/ChatViewer.test.tsx

cd ../vscode-ide-companion
npx vitest run src/services/qwenTranscriptText.test.ts src/services/qwenAgentManager.test.ts

cd ../desktop/packages/shared
npx --yes bun@1.3.9 test src/agent/__tests__/qwen-agent-slash-history.test.ts

Evidence (Before & After)

Automated transcript and resume regression evidence is provided instead of screenshots.

Surface Before After
Recorded user JSONL The model-bound user parts contained bare hook context with no clean display provenance. The exact model-bound parts remain, the context is tagged, and displayText plus hookContext record their provenance.
Resume / transcript UI The visible user entry could render "user prompt" followed by "hook-only context". The visible user entry renders only "user prompt".
Compatibility Consumers had to concatenate or invent product-specific stripping rules. Paired UserPromptSubmit metadata is authoritative, synthetic display labels do not replace model-bound text in general consumers, exact final tags have a safe fallback, and legacy bare records remain unchanged.

Observed focused results after the latest main merge and review follow-up: Core 563 tests passed; CLI 149; ACP 23; WebUI 8; VS Code 13; Desktop 43; no-AK integration smoke 52. The full build, typecheck, bundle, pre-commit Prettier, and ESLint checks also passed. The credentialed CLI integration suite was attempted locally; its model-backed cases require the repository OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL secrets and therefore could not be validated in this fork environment.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested locally
🐧 Linux ⚠️ not tested locally

Environment (optional)

macOS 26.3.1 on arm64, Node.js v26.5.0, npm 11.17.0, and Bun 1.3.9 for the Desktop regression suite.

Risk & Scope

  • Main risk or tradeoff: Transcript projection now has to remain consistent across several consumers; focused fixtures cover paired metadata, synthetic-record field collisions, tag-only, legacy, empty-display, and non-text cases across those boundaries.
  • Not validated / out of scope: Windows and Linux were not run locally; the subagent UserPromptSubmit path is intentionally unchanged.
  • Breaking changes / migration notes: None. Existing untagged transcripts keep their prior text, and no data migration is required.

Linked Issues

Closes #7940

中文说明

本 PR 做了什么

本 PR 将模型使用的 UserPromptSubmit Hook 上下文与用户可见的 transcript 文本分离。它把经过清理的 Hook 上下文包裹在独立的 <qwen:user-prompt-submit-context> part 中,在保留模型实际收到的完整 message parts 的同时记录干净的 displayText 与 hookContext 来源信息,并让 CLI Resume、Insight 生成、Session Reference、Goal Evidence、Conversation Branch、ACP Replay、WebUI、VS Code 和 Desktop 等 transcript 消费端统一使用干净投影。Telemetry 与自动记忆召回现在由 Hook 注入后的 prompt 改为使用注入前的用户 prompt,因此不再包含 UserPromptSubmit 新增的上下文。

为什么需要

此前 UserPromptSubmit 的 additionalContext 会被追加进 user request,随后这份已经扩展过的 request 又被直接写入记录。任何拼接全部 user message parts 的消费者,都会在恢复会话或分析 JSONL 时把 Hook 注入内容显示成用户亲自输入的文本。只修一个 UI 会继续留下含义不清的持久化数据,并在不同产品里复制脆弱的字符串删除逻辑;如果直接从记录消息中移除上下文,又会导致模型历史恢复不准确。因此本改动把模型视图和展示视图明确分离。

审阅者测试计划

如何验证

  1. 配置一个 UserPromptSubmit Hook,让它通过 additionalContext 返回 "hook-only context"。
  2. 提交用户 prompt "user prompt"。
  3. 检查写入的 user JSONL 记录。message parts 应保留模型实际收到的用户 prompt 和一个带标签的 Hook 上下文 part;systemPayload.displayText 应为 "user prompt",systemPayload.hookContext 应为 "hook-only context"。
  4. 通过 CLI 恢复会话,或经由 ACP、WebUI、VS Code、Desktop 重放记录。可见的用户消息应只包含 "user prompt";带标签的 Hook 上下文仍应保留给模型历史和离线消费者。
  5. 检查兼容情况:UserPromptSubmit 记录只有在 displayText 与 hookContext 成对出现时才以 displayText 为准,包括空字符串;notification、cron 和 mid-turn 的展示标签不会在通用 transcript 消费端替换模型正文;仅带标签的记录只删除完整且位于最后的精确上下文 part;旧版无标签记录保留原文本;图片等非文本 part 仍然可用。
  6. 运行以下定向回归测试与仓库检查命令:
npm run build
npm run typecheck
npm run bundle
npm run test:integration:no-ak:sandbox:none

cd packages/core
npx vitest run src/core/client.test.ts src/core/prompts.test.ts src/services/chatRecordingService.test.ts src/utils/transcript-records.test.ts src/hooks/user-prompt-submit-context.test.ts src/goals/goal-evidence.test.ts src/utils/conversation-branches.test.ts src/services/session-reference-service.test.ts

cd ../cli
npx vitest run src/services/insight/generators/DataProcessor.test.ts src/ui/utils/resumeHistoryUtils.test.ts src/acp-integration/session/history-replayer.test.ts

cd ../acp-bridge
npx vitest run src/transcript-replay.test.ts

cd ../webui
npx vitest run src/adapters/JSONLAdapter.test.ts src/components/ChatViewer/ChatViewer.test.tsx

cd ../vscode-ide-companion
npx vitest run src/services/qwenTranscriptText.test.ts src/services/qwenAgentManager.test.ts

cd ../desktop/packages/shared
npx --yes bun@1.3.9 test src/agent/__tests__/qwen-agent-slash-history.test.ts

证据(修改前与修改后)

这里使用自动化 transcript 与 Resume 回归证据代替截图。

范围 修改前 修改后
User JSONL 记录 模型使用的 user parts 包含裸 Hook 上下文,但没有干净的展示来源信息。 完整模型 parts 保持不变,上下文带有标签,并通过 displayText 与 hookContext 记录来源。
Resume / Transcript UI 可见用户消息可能显示 "user prompt",随后又显示 "hook-only context"。 可见用户消息只显示 "user prompt"。
兼容性 消费端只能全部拼接,或各自设计产品相关的字符串删除规则。 成对的 UserPromptSubmit metadata 具有权威性;合成记录的展示标签不会在通用消费端替换模型正文;精确的末尾标签具有安全回退;旧版裸记录保持不变。

在最新 main 合并及本轮审查修复后,已观察到的定向测试结果:Core 563 个测试通过;CLI 149;ACP 23;WebUI 8;VS Code 13;Desktop 43;无 AK integration smoke 52。全仓 Build、Typecheck、Bundle、Pre-commit Prettier 与 ESLint 检查也均已通过。完整的凭据型 CLI integration suite 已在本机尝试运行;其中依赖模型的用例需要仓库的 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL secrets,因此无法在当前 fork 环境完成验证。

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未在本机测试
🐧 Linux ⚠️ 未在本机测试

环境(可选)

macOS 26.3.1 arm64,Node.js v26.5.0,npm 11.17.0;Desktop 回归测试使用 Bun 1.3.9。

风险与范围

  • 主要风险或取舍:Transcript 投影需要在多个消费端保持一致;定向 fixtures 已跨越这些边界覆盖成对 metadata、合成记录字段冲突、tag-only、legacy、empty display 和非文本内容等场景。
  • 未验证 / 范围外:未在本机执行 Windows 与 Linux 测试;Subagent 的 UserPromptSubmit 路径按范围保持不变。
  • 破坏性变更 / 迁移说明:无。现有无标签 transcript 保持原文本,不需要数据迁移。

关联 Issue

Closes #7940

@yiliang114

Copy link
Copy Markdown
Collaborator

⚠️ Failed to process this request. Please re-mention the bot to retry.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @destire-mio, thanks for picking this up — the linked issue (#7940) is well-specified and the fix direction looks right.

Before we can review the code, the PR body needs to follow the PR template. Right now it uses custom headings (Summary, Root cause, Compatibility, Validation) instead of the required ones. Specifically, we need:

  • ## What this PR does and ## Why it's needed — your Summary and Root cause content maps well to these, just needs the right headings.
  • ## Reviewer Test Plan with ### How to verify, ### Evidence (Before & After), and ### Tested on — this is the big one. How should a reviewer confirm the fix? What does the JSONL look like before vs. after? Which OSes did you test on?
  • ## Risk & Scope — main risk/tradeoff, what's not validated, breaking changes.
  • ## Linked IssuesCloses #7940 is great, just put it under this heading.
  • <details><summary>中文说明</summary> — a Chinese translation of the above.

Once the body follows the template, re-run with @qwen-code /triage and we'll pick it right back up.

中文说明

@destire-mio 你好,感谢认领这个 issue(#7940)!修复方向看起来是对的。

在进入代码审查之前,PR 正文需要使用PR 模板中规定的标题格式。目前使用的是自定义标题(SummaryRoot causeCompatibilityValidation),需要改为:

  • ## What this PR does## Why it's needed —— 现有的 Summary 和 Root cause 内容可以直接对应,只需更换标题。
  • ## Reviewer Test Plan(含 ### How to verify### Evidence (Before & After)### Tested on)—— 这是关键部分:reviewer 如何验证修复?修复前后的 JSONL 长什么样?在哪些操作系统上测试过?
  • ## Risk & Scope —— 主要风险/权衡、未验证的部分、破坏性变更。
  • ## Linked Issues —— Closes #7940 放在这个标题下即可。
  • <details><summary>中文说明</summary> —— 上述内容的中文翻译。

更新正文后,使用 @qwen-code /triage 重新触发审查即可。

Qwen Code · qwen3.8-max-preview

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head d66130b. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 1 render-shaping file:

  • packages/webui/src/components/ChatViewer/ChatViewer.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@destire-mio

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

中文说明

已审查——无阻断问题。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Not reviewed: You are review agent reverse-audit — Reverse audit agen... — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.

中文说明

已审查。 未审查:You are review agent reverse-audit — Reverse audit agen...——它被指向 diff 的行却从未打开:有工具调用,但没有一次读取 diff。

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

The current head addresses the previous template request and is mergeable.

The only failing check is SDK Java 11. This PR does not modify packages/sdk-java; the same suite passed on Java 17 and Java 21. The failure is an EOF/DetachOutcomeUnknownException in slowSessionCreationDoesNotBlockExistingSessionMutation, so it appears transient.

Could a maintainer please rerun the failed job and refresh or dismiss the stale CHANGES_REQUESTED review from the previous head?

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +215 to +217
it('projects Qwen user transcript records without hook context', () => {
const extractQwenRecordText = (
QwenAgent.prototype as unknown as QwenHistoryInternals

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test file lives under packages/desktop/, which is excluded from the npm workspaces test collection (!packages/desktop in the root package.json), so the new extractQwenRecordText projection behaviour ships without any test that gates it in the npm-based CI jobs. — Concrete cost: a regression in extractQwenRecordText (e.g. failing to strip the <qwen:user-prompt-submit-context> tag, or returning hook context in the user-visible Desktop transcript) would pass undetected, because this test never runs in npm test or in the CI jobs that run the npm workspace test suites; the behaviour (clean transcript text in Desktop) would silently break. Consider wiring the packages/desktop bun test script into a CI job (the suite passes locally via bun test), so this regression is caught.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a valid concern. packages/desktop is intentionally isolated from the root npm workspace and synchronized with the standalone OpenWork repository, and the focused Bun test suite passes locally. Since adding Desktop to the required CI matrix is a repository-wide policy change, I’d prefer to handle it in a dedicated follow-up unless maintainers want it included in this PR.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max-preview via Qwen Code /review

: '',
)
.join('');
const text = projection.displayText ?? fallbackText;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new fallbackText mapping — the tag-stripping fallback for records that have no systemPayload.displayText — is never exercised by a test. The only new DataProcessor test feeds a record with systemPayload.displayText, so projection.parts is empty and fallbackText is discarded by the ??; the not.toContain('hook-only context') assertion passes via the displayText branch, not the fallback.

Concrete cost: every sibling consumer this PR adds (transcript-replay, resumeHistoryUtils, qwenTranscriptText, JSONLAdapter) tests the 'strips a complete final tag-only context part' case, but DataProcessor does not. A regression reverting the fallback to record.message.parts would re-leak hook context into insight analysis (the bug #7940 fixes) and stay green.

Suggested test (mirrors the sibling consumers):

it('strips a complete final tag-only context part without metadata', () => {
  const tag =
    '<qwen:user-prompt-submit-context>\nhook-only context\n</qwen:user-prompt-submit-context>';
  const records = [
    { type: 'user', message: { parts: [{ text: 'user prompt' }, { text: tag }] } },
  ];
  const result = formatRecordsForAnalysis(records as never[]);
  expect(result).toContain('[User]: user prompt');
  expect(result).not.toContain('hook-only context');
});
中文说明

新增的 fallbackText 映射(针对没有 systemPayload.displayText 的记录做标签删除的回退分支)没有任何测试覆盖。DataProcessor 唯一的新测试传入的是带 systemPayload.displayText 的记录,因此 projection.parts 为空,fallbackText 会被 ?? 丢弃;not.toContain('hook-only context') 断言是经由 displayText 分支通过的,而非回退删除。

具体代价:本 PR 新增的其它所有消费端(transcript-replay、resumeHistoryUtils、qwenTranscriptText、JSONLAdapter)都测试了'删除完整且位于最后的纯标签上下文 part'场景,唯独 DataProcessor 没有。一旦有回归把回退改回 record.message.parts,Hook 上下文就会重新泄漏进 insight 分析(即 #7940 要修的 bug),而测试仍会通过。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +49 to +51
if (payload && typeof payload.displayText === 'string') {
return payload.displayText;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The webui contract 'an empty displayText is meaningful — do not fall back to model-facing parts' has no test in this package. JSONLAdapter.test.ts only feeds a non-empty displayText ('raw @file prompt'); a grep for an empty displayText across packages/webui returns nothing, while the vscode sibling (qwenTranscriptText.test.ts) asserts exactly this case (displayText: '''').

Concrete cost: the guard below is correct today (typeof payload.displayText === 'string' admits ''). But if a maintainer simplifies it to a truthy check (if (payload.displayText)), an empty displayText falls through to the parts-join and the webui chat renders the model-expanded prompt plus the hook context — the precise leak this PR exists to prevent — and JSONLAdapter.test.ts stays green. The vscode suite would catch it; the webui suite would not.

Suggested test (mirrors the vscode test):

it('does not fall back to model-facing parts for empty display metadata', () => {
  const messages = [
    {
      type: 'user',
      message: { parts: [{ text: 'expanded model prompt' }] },
      systemPayload: { displayText: '', hookContext: 'hook-only context' },
    },
  ];
  const [adapted] = adaptJSONLMessages(messages as JSONLMessage[]);
  expect(adapted?.content).toBe('');
});
中文说明

webui 的'空 displayText 有意义——不得回退到模型侧 parts'这一约定在本包没有测试。JSONLAdapter.test.ts 只传入非空 displayText('raw @file prompt');在整个 packages/webui 中搜索空 displayText 没有任何结果,而 vscode 的同类测试(qwenTranscriptText.test.ts)恰好断言了这一场景(displayText: '''')。

具体代价:此处的守卫目前是正确的(typeof payload.displayText === 'string' 接受 '')。但若有人将其简化为真值判断(if (payload.displayText)),空 displayText 就会回退到 parts 拼接,webui 聊天将渲染出模型扩展后的 prompt 加 Hook 上下文——正是本 PR 要防止的泄漏——而 JSONLAdapter.test.ts 仍会通过。vscode 的测试能抓到,webui 的测试抓不到。

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Review: separate hook context from transcript display

What it does

Wraps UserPromptSubmit additionalContext in a <qwen:user-prompt-submit-context> part, persists systemPayload.displayText / hookContext alongside the unchanged model-bound parts, and adds one core chokepoint — projectUserTranscriptForDisplay (packages/core/src/utils/transcript-records.ts:156) — that every transcript consumer now routes through. Telemetry and auto-memory recall are switched to the pre-hook prompt text.

The chokepoint choice is good: /export (via HistoryReplayercreateTranscriptReplayMachine) and the resume-picker SessionPreview (via buildResumedHistoryItems) are fixed transitively without being touched. Treating displayText: '' as meaningful rather than ||-falsy is the right call and is tested at every boundary. The tag boundary is unspoofable — UserPromptSubmitHookOutput.getAdditionalContext() escapes every </> (hooks/types.ts:439), so the body.includes(OPEN|CLOSE) guard plus exact-prefix/suffix plus final-position conditions keep the metadata-less heuristic properly conservative. Legacy untagged records really are left alone.


1. displayText collides with an existing payload field (should fix before merge)

notification, cron, and mid_turn_user_message records already store NotificationRecordPayload.displayText — a short UI label — while message.parts carries the substantive body (chatRecordingService.ts recordNotificationLike / recordMidTurnUserMessage). BackgroundNotificationQueueItem has displayText and modelText as separate fields, and recordNotification(notificationParts, item.displayText) writes parts: [{ text: item.modelText }].

projectUserTranscriptForDisplay treats any type: 'user' record with a string displayText as authoritative and filters out every text part. It never checks subtype. Consumers that also don't check subtype therefore change behavior:

  • session-reference-service.ts:167 (visibleUserText, used by recordsToLines and deriveTitle) — a background-agent or cron turn now renders User: Background agent completed instead of the notification/cron body. This text is injected into a model prompt, so it's real information loss on a model-facing path. deriveTitle can likewise pick a label instead of the prompt.
  • insight/DataProcessor.ts:219 — same substitution in insight analysis input.
  • webui JSONLAdapter/ChatViewer, vscode qwenSessionReader/qwenAgentManager, desktop qwen-agent — notification/cron rows switch from body to label. Arguably nicer in a UI, but it's undeclared and untested.
  • goal-evidence.ts:543,570 — the real_user gate admits mid_turn_user_message (coherentEvidenceProvenance), so mid-turn evidence content/preview become displayText instead of the recorded parts that prefixMidTurnUserMessageParts built. That changes goal proof content.

UserPromptRecordPayload always carries hookContext, which makes it self-discriminating. Suggested fix — gate the metadata branch on the discriminator (or on record.subtype === undefined) and restore the ACP replay's subtype-based displayText read for the four synthetic subtypes:

const isUserPromptPayload = payload && typeof payload['hookContext'] === 'string';
const displayText =
  isUserPromptPayload && typeof payload['displayText'] === 'string'
    ? payload['displayText']
    : undefined;

Please add a regression test asserting a notification record still projects its modelText. No test in this PR would catch the swap.

2. Docs are not updated

docs/users/features/hooks.md documents additionalContext but not that the model now receives it wrapped in <qwen:user-prompt-submit-context>, and nothing documents the two new persisted JSONL fields. Both are third-party-visible contracts (hook authors; anyone parsing transcripts). Also worth deciding whether the system prompt should explain the new tag the way it does for <system-reminder> — right now every hook turn ships the model an unexplained envelope, and no test covers how the model reads it.

3. hookContext is write-only and duplicates message.parts

git grep hookContext outside tests hits exactly two lines: the write in client.ts:2318 and the type at chatRecordingService.ts:409. Nothing reads it. Since the same sanitized string is already in the tagged part, every hook turn now persists it twice — a hook that injects a large file doubles that turn's JSONL bytes. Either drop it (recoverable from the tag) or state that it's an intentional external-consumer contract.

4. The PR body mischaracterizes the telemetry/recall change

Telemetry and automatic-memory recall continue to use the pre-hook user prompt.

Before this PR both used partToString(request) after the append — i.e. post-hook. This PR changes them to pre-hook (client.ts:2443, :2499). It's a good change and the new client.test.ts case covers it, but it should be declared as a behavior change: recall queries and the sensitive prompt span attribute no longer contain hook context. (For non-UserQuery top-level interactions — Hook, ToolResult, TeammatepreHookUserPromptText is undefined, so the span attribute now carries the tag markup where it previously carried bare context. Cosmetic, but inconsistent.)

5. Three parallel copies of the tag heuristic

The literals and isHookContextPart logic exist in core (transcript-records.ts), packages/webui/src/adapters/userTranscriptDisplay.ts:40, and packages/desktop/.../qwen-agent.ts (projectQwenUserRecordText). Neither webui nor desktop depends on @qwen-code/qwen-code-core, so the duplication is unavoidable — but nothing keeps them in sync, and a future tag rename would silently stop stripping in two surfaces while core's tests stay green. At minimum cross-reference USER_PROMPT_SUBMIT_CONTEXT_OPEN in a comment; better, assert the literal in each package's test so a rename fails CI. (Join separators are correctly preserved per surface: '' webui, '\n' vscode, '\n\n' desktop.)

6. displayText means different things per client

options.submittedPrompt is supplied only by the TUI (AppContainer.tsx:2092). So TUI turns persist the raw typed prompt while ACP/serve/headless turns persist the fully @-expanded pre-hook prompt. Both beat the status quo, so this isn't a regression — but the field comment "User-authored prompt projection, before hook augmentation" is only accurate for the TUI, and the same session will render different user text in different clients. Suggest wording it as "pre-hook prompt text" and noting the split in the compatibility section.

7. One CLI resume path still bypasses the projection

resumeHistoryUtils.ts:363 handles at_command turns with payload.userText || extractTextFromParts(record.message?.parts), ahead of the updated generic branch at :387. AtCommandRecordPayload.userText is optional, so when it's absent an @-command turn with hook context still renders the raw <qwen:user-prompt-submit-context> block. Low likelihood (both writers populate it) but it's the one remaining raw-parts fallback on a display path — route it through the projection too.

Nits

  • transcript-replay.ts:15-18import type { … } followed immediately by import { projectUserTranscriptForDisplay } from the same specifier; merge them.
  • UserTranscriptDisplayProjection.parts: readonly unknown[] forces as readonly Part[] casts at four call sites. A generic part type (<T>(record: { message?: { parts?: readonly T[] } … }) => { parts: readonly T[] }) removes all of them.
  • webui getUserTranscriptDisplayText(record, separator = '') — no caller passes separator; dead parameter.
  • DataProcessor builds fallbackText eagerly even when displayText is present, and re-implements a parts→text join.
  • goal-evidence calls the projection twice per record (content + preview); one call threaded through reads better.
  • ACP replay ordering: with metadata the displayText chunk is emitted before the retained non-text parts, so a record whose parts were [image, text] replays as [text, image]. The new test's fixture is [text, image, tag], which hides this. Minor, but worth a fixture.

Correctness / security / perf notes that check out

  • Tag injection: not reachable — all </> in additionalContext are escaped before wrapping.
  • parts.length > 1 guard correctly stops a tag-only record from projecting to nothing.
  • The tagged part is always last at record time (request is reassigned exactly once, in the hook block), so the metadata-less fallback's final-position assumption holds.
  • createTranscriptMessageUpdate({ role: 'user', text, ...meta }) produces byte-identical shape to what projectMessageParts emits for a text part — no _meta divergence for ACP clients.
  • recordUserMessage is reached only for UserQuery, matching exactly where userPromptRecordPayload is populated.
  • Perf impact is negligible (one filter allocation per record).

Verdict

Solid design and unusually thorough cross-surface test coverage. #1 is the blocker — it silently changes notification/cron/mid-turn rendering on a model-facing path, and the fix is a one-line discriminator plus a test. #2 (docs) should land with it. The rest are polish.

中文说明

总体评价

设计方向正确:新增 projectUserTranscriptForDisplay 作为唯一收敛点,/export(经 HistoryReplayer)与恢复预览 SessionPreview(经 buildResumedHistoryItems)无需改动即被顺带修复;把 displayText: '' 当作有意义的值而非 falsy 处理是对的,且各消费端都有测试;标签边界不可伪造——getAdditionalContext() 会转义所有 </>

1. displayText 与既有字段语义冲突(合并前建议修复)

notification / cron / mid_turn_user_message 记录早已使用 NotificationRecordPayload.displayText 存放简短 UI 标签,而 message.parts 存放真正的正文(recordNotification(parts=[{text: item.modelText}], item.displayText))。新的投影函数只要看到 type: 'user'displayText 为字符串就视为权威,并丢弃所有 text part,从不检查 subtype。后果:

  • session-reference-service.ts:167:后台 Agent / Cron 轮次变成 User: Background agent completed,正文丢失——而这段文本会被注入模型提示词,属于模型可见路径的信息损失;deriveTitle 也可能取到标签。
  • insight/DataProcessor.ts:219:Insight 分析输入同样被替换。
  • webui / vscode / desktop:通知行由正文变标签(UI 上或许更好,但未声明、未测试)。
  • goal-evidence.ts:543,570real_user 判定放行 mid_turn_user_message,导致 Goal 证据内容改变(即 proof 内容改变)。

UserPromptRecordPayload 必带 hookContext,天然可作判别字段。建议以 typeof payload['hookContext'] === 'string'(或 record.subtype === undefined)作为进入 metadata 分支的门槛,并恢复 ACP replay 中针对四类合成 subtype 的原有 displayText 读取;同时补一条「notification 记录仍投影出 modelText」的回归测试——本 PR 现有测试无法捕获该问题。

2. 文档未更新

docs/users/features/hooks.md 说明了 additionalContext,但未提及模型现在收到的是被 <qwen:user-prompt-submit-context> 包裹的内容,也没有文档描述两个新的 JSONL 字段。两者都是第三方可见契约。另建议确认是否需要像 <system-reminder> 那样在系统提示词中解释该标签。

3. hookContext 只写不读且与 parts 重复

除测试外全仓仅两处命中:client.ts:2318 的写入与 chatRecordingService.ts:409 的类型声明。同一份文本已存在于带标签的 part 中,等于每个 Hook 轮次都重复持久化一次;注入大文件的 Hook 会使该轮 JSONL 体积翻倍。建议删除或明确声明为对外契约。

4. PR 描述对遥测/召回的表述不准确

描述称「Telemetry 与自动记忆召回继续使用 Hook 注入前的 prompt」,但改动前两者用的是追加之后的文本。本 PR 实际把它们改成了注入前(client.ts:2443:2499)。改动本身是好的且有测试覆盖,但应作为行为变更声明。另外非 UserQuery 的顶层交互(Hook/ToolResult/Teammate)中,span 属性现在会带上标签标记,属细节不一致。

5. 标签判定逻辑存在三份副本

core、packages/webui/src/adapters/userTranscriptDisplay.ts:40packages/desktop/.../qwen-agent.ts 各有一份。webui 与 desktop 都不依赖 core,重复不可避免;但没有任何机制保证同步——将来重命名标签会让两个界面静默失效而 core 测试仍然通过。建议至少加注释交叉引用 USER_PROMPT_SUBMIT_CONTEXT_OPEN,更好的做法是在各包测试中断言字面量,使重命名在 CI 失败。(三处 join 分隔符 ''/'\n'/'\n\n' 均正确保留。)

6. displayText 在不同客户端含义不同

options.submittedPrompt 只有 TUI(AppContainer.tsx:2092)会传,因此 TUI 轮次存的是用户原始输入,而 ACP/serve/headless 轮次存的是 @ 展开后的注入前 prompt。两者都优于现状,不算回退;但字段注释「User-authored prompt projection」仅对 TUI 成立,且同一会话在不同客户端展示会不一致。建议改写为「注入前的 prompt 文本」并在兼容性小节说明。

7. 仍有一条 CLI 恢复路径绕过投影

resumeHistoryUtils.ts:363at_command 分支使用 payload.userText || extractTextFromParts(...),位于已修改的通用分支(:387)之前。userText 是可选字段,缺失时带 Hook 上下文的 @ 命令轮次仍会渲染出原始标签块。概率很低(两处写入方都会填),但这是展示路径上最后一处裸 parts 回退,建议一并接入投影。

其他小问题

  • transcript-replay.ts:15-18 对同一模块写了两条 import,应合并。
  • parts: readonly unknown[] 导致四处 as readonly Part[] 断言;改为泛型即可全部消除。
  • webui getUserTranscriptDisplayTextseparator 参数无人使用。
  • DataProcessordisplayText 存在时仍会先算 fallbackText
  • goal-evidence 对同一记录调用投影两次。
  • ACP replay 顺序:带 metadata 时 displayText 先于保留的非文本 part 发出,因此原本 [image, text] 的记录会变成 [text, image];新测试用例是 [text, image, tag],恰好掩盖了这一点,建议补 fixture。

结论

设计扎实,跨界面测试覆盖罕见地完整。第 1 点是阻塞项——它在模型可见路径上静默改变了 notification/cron/mid-turn 的渲染,而修复只需一行判别条件加一条测试;第 2 点(文档)建议同期落地,其余属打磨。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by CI's linux Test job and locally).

中文说明

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by CI's linux Test job and locally)。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +179 to +180
const isUserPromptPayload =
payload && typeof payload['hookContext'] === 'string';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Compatibility regression: this discriminator (and the matching gates in the desktop and webui duplicates) requires hookContext to treat displayText as the user-prompt projection, and the tag-strip fallback below requires systemPayload === undefined. Released v0.21.2/v0.21.3 (PR #7956, commit 953c9d817) wrote user-prompt records with systemPayload: { displayText } and no hookContext, with the tagged <qwen:user-prompt-submit-context> block still in message.parts. Those records satisfy neither branch, so the projection returns the raw model-facing parts — leaking the literal XML block into TUI resume, ACP/export replay, insights, session-reference, goal-evidence, and conversation-branches. — Failure scenario: a user who ran v0.21.2/v0.21.3 with a UserPromptSubmit hook (e.g. any session with QWEN.md context) resumes that session → the user turn renders the expanded model prompt plus the raw <qwen:user-prompt-submit-context>…</…> block, the exact content this feature exists to keep out of display surfaces. The consumers this PR replaced (extractUserRecordDisplayText, transcript-replay's withUserPromptDisplayText) honored the single-field displayText. Note the new test does not trust bare displayText on plain user records asserts this leaky output as intended — but released single-field records are byte-identical to that "plain" shape. — Suggested fix: also accept the released single-field shape, using the tagged part as pairing evidence (a released user-prompt record carries the complete tagged context part; notification/cron/mid-turn labels never do) — e.g. treat displayText as authoritative when typeof payload['displayText'] === 'string' and either hookContext is a string or the parts end in a complete tagged context part; add a regression fixture shaped like the v0.21.2 record (displayText without hookContext). The fix must also land in the desktop (qwen-agent.ts) and webui (userTranscriptDisplay.ts) duplicates, which do not import core.

中文说明

[Critical] 兼容性回退:该判别条件(以及 desktop 与 webui 中对应的两份副本)要求存在 hookContext 才会把 displayText 视作用户 prompt 投影,而下方的 tag-strip 回退又要求 systemPayload === undefined。已发布的 v0.21.2/v0.21.3(PR #7956,commit 953c9d817)写入的 user-prompt 记录只有 systemPayload: { displayText }没有 hookContext,且带标签的 <qwen:user-prompt-submit-context> 块仍留在 message.parts 中。这类记录两个分支都不满足,因此投影会返回原始的模型 parts——把字面 XML 块泄漏到 TUI resume、ACP/export replay、insights、session-reference、goal-evidence 和 conversation-branches。— 失败场景:用户在 v0.21.2/v0.21.3 上使用带 UserPromptSubmit hook 的会话(例如任何含 QWEN.md 上下文的会话)并 resume 该会话时,用户轮次会渲染出扩展后的模型 prompt 加上原始 <qwen:user-prompt-submit-context>…</…> 块,正是本功能要从展示界面排除的内容。本 PR 替换掉的消费端(extractUserRecordDisplayText、transcript-replay 的 withUserPromptDisplayText)原本会采纳单字段 displayText。注意新测试 does not trust bare displayText on plain user records 把这种泄漏结果断言为预期行为——但已发布的单字段记录与该“plain”形状在字节上完全一致。— 建议修复:同时接受已发布的单字段形状,以带标签的 part 作为配对证据(已发布的 user-prompt 记录带有完整的末尾标签 part;notification/cron/mid-turn 标签则从不带)——例如当 typeof payload['displayText'] === 'string'hookContext 为字符串或 parts 以完整标签 part 结尾时,将 displayText 视为权威;并补一个形如 v0.21.2 记录(有 displayText、无 hookContext)的回归 fixture。修复还必须同步到 desktop(qwen-agent.ts)与 webui(userTranscriptDisplay.ts)两份不依赖 core 的副本。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +989 to +993
const displayText =
payload && asString(payload.hookContext) !== undefined
? asString(payload.displayText)
: undefined;
if (displayText !== undefined) return displayText;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same compatibility regression as the core projectUserTranscriptForDisplay, in this standalone desktop duplicate (desktop has no @qwen-code/qwen-code-core dependency, so a core-only fix will NOT reach this path). The metadata gate requires hookContext; the tag-strip gate below requires systemPayload === undefined. Released v0.21.2/v0.21.3 single-field { displayText } records satisfy neither, so extractQwenRecordText returns the raw parts — including the <qwen:user-prompt-submit-context> block — joined with \n\n into the desktop history UI. — Failure scenario: the desktop app loads a v0.21.2/v0.21.3 session that used a UserPromptSubmit hook → the user turn displays the expanded model prompt plus the raw XML context block. The desktop test at qwen-agent-slash-history.test.ts:265-272 asserts .toContain('trusted context') for a single-field record, so it blesses the leak and stays green even after a core fix. — Suggested fix: mirror the core fix here (accept single-field displayText when the parts end in a complete tagged context part), or have desktop delegate to a shared projection; update the test assertion accordingly.

中文说明

[Critical] 与 core projectUserTranscriptForDisplay 相同的兼容性回退,出现在这份独立的 desktop 副本中(desktop 不依赖 @qwen-code/qwen-code-core,因此仅修 core 不会覆盖此路径)。metadata 分支要求 hookContext;下方的 tag-strip 分支要求 systemPayload === undefined。已发布的 v0.21.2/v0.21.3 单字段 { displayText } 记录两者都不满足,于是 extractQwenRecordText 返回原始 parts——包括 <qwen:user-prompt-submit-context> 块——以 \n\n 拼接进 desktop 历史界面。— 失败场景:desktop 加载一个用过 UserPromptSubmit hook 的 v0.21.2/v0.21.3 会话时,用户轮次会显示扩展后的模型 prompt 加上原始 XML 上下文块。qwen-agent-slash-history.test.ts:265-272 的 desktop 测试对单字段记录断言 .toContain('trusted context'),因此它把该泄漏断言为正确,即便修了 core 也仍然通过。— 建议修复:在此处同步 core 的修复(当 parts 以完整标签 part 结尾时接受单字段 displayText),或让 desktop 委托给共享投影;并相应更新测试断言。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +50 to +56
if (
payload &&
typeof payload.hookContext === 'string' &&
typeof payload.displayText === 'string'
) {
return payload.displayText;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same compatibility regression as the core projectUserTranscriptForDisplay, in this standalone webui duplicate (webui imports only @qwen-code/sdk, never core, so a core-only fix will NOT reach this path). The metadata gate requires both hookContext and displayText strings; the tag-strip gate below requires systemPayload === undefined. Released v0.21.2/v0.21.3 single-field { displayText } records satisfy neither, so getUserTranscriptDisplayText returns the raw concatenated model-facing parts — including the <qwen:user-prompt-submit-context> block — and the ?? extractContent fallback in JSONLAdapter.ts/ChatViewer.tsx never engages because the return is a non-null string. — Failure scenario: a v0.21.2/v0.21.3 session opened in the webui ChatViewer renders the internal hook-context XML in the user message. JSONLAdapter.test.ts ('keeps tag-like parts when another system payload is present') feeds a single-field record and asserts .toContain('user-authored text'), blessing the leak; a probe confirmed the output flips to clean and this test flips pass→fail under a fix. — Suggested fix: mirror the core fix here (accept single-field displayText when the parts end in a complete tagged context part) and update the two JSONLAdapter.test.ts cases, or have webui consume a shared projection.

中文说明

[Critical] 与 core projectUserTranscriptForDisplay 相同的兼容性回退,出现在这份独立的 webui 副本中(webui 只依赖 @qwen-code/sdk,从不依赖 core,因此仅修 core 不会覆盖此路径)。metadata 分支要求 hookContextdisplayText 同时为字符串;下方的 tag-strip 分支要求 systemPayload === undefined。已发布的 v0.21.2/v0.21.3 单字段 { displayText } 记录两者都不满足,于是 getUserTranscriptDisplayText 返回原始拼接的模型 parts——包括 <qwen:user-prompt-submit-context> 块——而 JSONLAdapter.ts/ChatViewer.tsx 中的 ?? extractContent 回退永远不会触发,因为返回值是非空字符串。— 失败场景:在 webui ChatViewer 中打开一个 v0.21.2/v0.21.3 会话时,用户消息会渲染出内部 hook 上下文 XML。JSONLAdapter.test.ts('keeps tag-like parts when another system payload is present')传入单字段记录并断言 .toContain('user-authored text'),把泄漏断言为正确;探针确认在修复后输出会变为干净值且该测试由通过变为失败。— 建议修复:在此处同步 core 的修复(当 parts 以完整标签 part 结尾时接受单字段 displayText)并更新 JSONLAdapter.test.ts 的两个用例,或让 webui 使用共享投影。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +142 to +144
const prefix = `${USER_PROMPT_SUBMIT_CONTEXT_OPEN}\n`;
const suffix = `\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE}`;
if (!trimmed.startsWith(prefix) || !trimmed.endsWith(suffix)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The tightened matcher now requires newline delimiters between the tags and the body (rejecting the newline-less input the old version accepted), but no test pins this: dropping the \n from both prefix and suffix (restoring the old loose matching) leaves all 26 matcher tests green. — Concrete cost: a future change that loosens the delimiter requirement would silently re-accept user-authored text that merely starts/ends with the tag strings, and nothing in CI would catch it. — Suggested fix: add a negative case to the isUserPromptSubmitContextPartText tests asserting a complete tag block without the newline delimiters returns false.

中文说明

[Suggestion] 收紧后的匹配器现在要求标签与正文之间有换行分隔符(会拒绝旧版本接受的无换行输入),但没有测试固定这一点:把 prefixsuffix 中的 \n 去掉(恢复旧的宽松匹配)后,全部 26 个匹配器测试仍然通过。— 具体代价:未来任何放宽分隔符要求的改动都会静默地重新接受仅仅以标签字符串开头/结尾的用户自撰文本,而 CI 不会捕获。— 建议修复:在 isUserPromptSubmitContextPartText 测试中补一个反例,断言一个不带换行分隔符的完整标签块返回 false

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +215 to +218
it('projects Qwen user transcript records without hook context', () => {
const extractQwenRecordText = (
QwenAgent.prototype as unknown as QwenHistoryInternals
).extractQwenRecordText;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This is the only test in the diff covering the desktop extractQwenRecordText hook-context stripping, but it is never collected by this repo's test command — packages/desktop is excluded from the root npm workspaces (!packages/desktop) and is a separate bun-based workspace; no npm CI job runs it (.github/workflows/ci.yml has only a check:desktop-isolation step). — Concrete cost: if the desktop projection regresses (e.g. stops preferring displayText, or leaks the <qwen:user-prompt-submit-context> tag), this new test would catch it — but it does not run in this repo's CI, so the regression would ship undetected unless the separate openwork pipeline that owns packages/desktop runs bun test. — Suggested fix: confirm the desktop suite runs in the openwork/desktop CI pipeline; if not, add a job that runs bun test there so this coverage gates the change.

中文说明

[Suggestion] 这是本 diff 中唯一覆盖 desktop extractQwenRecordText hook 上下文剥离的测试,但它永远不会被本仓库的测试命令收集——packages/desktop 被排除在根 npm workspaces 之外(!packages/desktop),是一个独立的基于 bun 的 workspace;没有 npm CI job 运行它(.github/workflows/ci.yml 只有 check:desktop-isolation 一步)。— 具体代价:如果 desktop 投影回退(例如不再优先使用 displayText,或泄漏 <qwen:user-prompt-submit-context> 标签),这个新测试本可以捕获——但它不在本仓库 CI 中运行,因此除非拥有 packages/desktop 的独立 openwork 流水线运行 bun test,否则回退会未被发现地发布。— 建议修复:确认 desktop 套件在 openwork/desktop CI 流水线中运行;若没有,则在那里添加运行 bun test 的 job,使该覆盖能门禁此改动。

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by CI's linux Test job and locally).

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by CI's linux Test job and locally)。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +197 to +199
if (record.systemPayload === undefined && hasFinalHookContextPart) {
return { displayText: undefined, parts: parts.slice(0, -1) };
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The tag-strip fallback tests the raw record.systemPayload === undefined, but the metadata branch one line above uses the object-checked payload (isObjectRecord(record.systemPayload) ? record.systemPayload : undefined). A non-object, non-undefined systemPayload (e.g. null) falls through both branches: payload is undefined so displayText is not honored, and record.systemPayload === undefined is false so the final hook-context part is not stripped — the reserved <qwen:user-prompt-submit-context> tag leaks into the clean display projection, contradicting the function's own doc comment ("For tag-only third-party records with no metadata, only that complete final part is removed"). A probe confirmed the output flips from leaky to clean under the fix. — Failure scenario: a third-party- or hand-edited JSONL user record with "systemPayload": null (JSON serializers commonly emit null for absent fields; validateTranscriptRecord spreads ...value through unsanitized) whose final part is a canonical wrapped context block and parts.length > 1 → the raw tag text surfaces in conversation-branches lastUserText, derived session titles, and injected session references. The same pattern is mirrored in packages/webui/src/adapters/userTranscriptDisplay.ts (~L65) and packages/desktop/packages/shared/src/agent/qwen-agent.ts (~L1004); the fix should land in all three.

Suggested change
if (record.systemPayload === undefined && hasFinalHookContextPart) {
return { displayText: undefined, parts: parts.slice(0, -1) };
}
if (payload === undefined && hasFinalHookContextPart) {
return { displayText: undefined, parts: parts.slice(0, -1) };
}
中文说明

[Suggestion] tag-strip 回退判断的是原始的 record.systemPayload === undefined,而上一行的 metadata 分支使用的是经过对象检查的 payloadisObjectRecord(record.systemPayload) ? record.systemPayload : undefined)。当 systemPayload 是非对象且非 undefined 的值(例如 null)时会同时绕过两个分支:payloadundefined,因此 displayText 不被采纳;而 record.systemPayload === undefinedfalse,因此末尾的 hook-context part 不会被移除——保留的 <qwen:user-prompt-submit-context> 标签会泄漏进干净展示投影,与函数自身的文档注释(“对于无 metadata 的仅带标签的第三方记录,只移除完整的末尾 part”)相矛盾。探针确认修复后输出由泄漏变为干净。— 失败场景:第三方或手工编辑的 JSONL user 记录带有 "systemPayload": null(JSON 序列化器常把缺失字段写为 nullvalidateTranscriptRecord 会原样 ...value 透传而不做清理),且其末尾 part 是规范的带标签上下文块、parts.length > 1 时 → 原始标签文本会出现在 conversation-branches 的 lastUserText、派生的会话标题以及注入的 session reference 中。同样的模式也存在于 packages/webui/src/adapters/userTranscriptDisplay.ts(约 L65)与 packages/desktop/packages/shared/src/agent/qwen-agent.ts(约 L1004);修复应同步到这三处。

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by CI's linux Test job and by this review's focused suite runs). Test Plan (not a blocker): src/core/client.test.tsno such file or directory; src/core/prompts.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; src/utils/transcript-records.test.tsno such file or directory; src/hooks/user-prompt-submit-context.test.tsno such file or directory; and 12 more.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by CI's linux Test job and by this review's focused suite runs)。 Test Plan(非阻断):src/core/client.test.tsno such file or directory; src/core/prompts.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; src/utils/transcript-records.test.tsno such file or directory; src/hooks/user-prompt-submit-context.test.tsno such file or directory; and 12 more。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment on lines +178 to +181
if (!replacedText && displayText.length > 0) {
projected.push({ text: displayText });
}
replacedText = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The test-efficacy probe (mutant analysis, harness validated) found this in-loop replacement guard's false side is unexercised: forcing !replacedText && displayText.length > 0 to true leaves every test in transcript-replay.test.ts green. No added test feeds a record with multiple text parts, where replacedText is already true when a later text part is reached. — Concrete cost: a regression that injects displayText into every text part (dropping the !replacedText half) would pass the whole added suite while duplicating display text across multi-part user records. Fix: add a replaceTextPartsForDisplay case with two text parts asserting the replacement happens exactly once, at the first text position.

中文说明

[Suggestion] 测试有效性探针(变异分析,harness 已验证)发现这个循环内替换分支的“假”侧没有任何测试覆盖:将 !replacedText && displayText.length > 0 强制为 truetranscript-replay.test.ts 中所有测试仍然通过。新增测试没有喂入含多个 text part 的记录(即后续 text part 到达时 replacedText 已为 true)。— 具体代价:一个把 displayText 注入到每个 text part(去掉 !replacedText 这一半)的回退,会让整个新增套件全部通过,同时在多 part 用户记录里重复插入展示文本。修复:补一个含两个 text part 的 replaceTextPartsForDisplay 用例,断言替换只在第一个 text 位置发生一次。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment on lines +186 to +189
if (!replacedText && displayText.length > 0) {
projected.push({ text: displayText });
}
return projected;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same probe: this trailing-append guard's false side is unexercised — no test covers the path where no text part was replaced (replacedText stays false) while displayText is empty. Forcing the condition true leaves every test green. — Concrete cost: a regression that always appends { text: displayText } (e.g. a spurious empty { text: '' } part when displayText is empty) would not fail any test in this diff. Fix: add a case with empty displayText and no text parts asserting no extra part is appended.

中文说明

[Suggestion] 同一探针:这个末尾追加分支的“假”侧没有被覆盖——没有测试覆盖“没有 text part 被替换(replacedText 保持 false)且 displayText 为空”的路径。将该条件强制为 true,所有测试仍通过。— 具体代价:一个总是追加 { text: displayText } 的回退(例如 displayText 为空时追加多余的空 { text: '' } part)不会让本 diff 的任何测试失败。修复:补一个“空 displayText + 无 text part”的用例,断言不追加多余 part。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 14400 seconds (of the 240-minute budget). This run already used the maximum 240 minute timeout. See workflow logs.

@wenshao

wenshao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #7948 does not currently have merge conflicts with main.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 14400 seconds (of the 240-minute budget). This run already used the maximum 240 minute timeout. See workflow logs.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by this review's workspace runs). Not reviewed: build-and-test — packages/desktop bun workspace: excluded from the npm workspace graph and no CI job collects packages/desktop/packages/shared/**; bun unavailable on this runner, so the desktop projection mirror was verified by static trace and extracted-code probes only. Test Plan (not a blocker): src/core/client.test.tsno such file or directory; src/core/prompts.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; src/utils/transcript-records.test.tsno such file or directory; src/hooks/user-prompt-submit-context.test.tsno such file or directory; and 13 more.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (the unit-test ground was covered by this review's workspace runs)。 未审查:build-and-test — packages/desktop bun workspace: excluded from the npm workspace graph and no CI job collects packages/desktop/packages/shared/**; bun unavailable on this runner, so the desktop projection mirror was verified by static trace and extracted-code probes only。 Test Plan(非阻断):src/core/client.test.tsno such file or directory; src/core/prompts.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; src/utils/transcript-records.test.tsno such file or directory; src/hooks/user-prompt-submit-context.test.tsno such file or directory; and 13 more。

— qwen3.8-max via Qwen Code /review (v0.21.7)

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 () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The rewritten test deleted the only assertion pinning "no systemPayload when no prompt payload is provided" (expect(plain.systemPayload).toBeUndefined() from the old stores hook display provenance in systemPayload only when provided test); no remaining test covers the absent branch of ...(promptPayload ? { systemPayload: promptPayload } : {}) in recordUserMessage. — Failure scenario: a future regression that records a systemPayload for plain, hook-free prompts (the conditional spread dropped, or payload construction hoisted out of the additionalContext guard) passes CI silently; downstream that flips projectUserTranscriptForDisplay off its tag-strip branch (payload === undefined is required), so metadata-free records with a trailing tagged part keep the raw <qwen:user-prompt-submit-context> block in every display consumer (resume, ACP replay, insights, webui, vscode, desktop). A review probe confirmed the class: forcing systemPayload: {} on payload-free records passed all 70 tests in this file; restoring the deleted assertion caught it. — Suggested fix: keep a negative case:

chatRecordingService.recordUserMessage([{ text: 'plain prompt' }]);
await chatRecordingService.flush();
expect((vi.mocked(jsonl.writeLine).mock.calls.at(-1)![1] as ChatRecord).systemPayload).toBeUndefined();
中文说明

重写后的测试删除了唯一钉住「未提供 prompt payload 时不写入 systemPayload」的断言(旧测试 stores hook display provenance in systemPayload only when provided 中的 expect(plain.systemPayload).toBeUndefined());recordUserMessage...(promptPayload ? { systemPayload: promptPayload } : {}) 的缺省分支不再有任何测试覆盖。— 失败场景:未来某个回归让普通无 Hook 的 prompt 也写入 systemPayload(例如条件展开被删、payload 构造被提出 additionalContext 判断之外),CI 会静默通过;下游这会使 projectUserTranscriptForDisplay 偏离 tag-strip 分支(该分支要求 payload === undefined),导致带尾部标签 part 的无 metadata 记录在所有展示消费端(resume、ACP replay、insights、webui、vscode、desktop)保留原始 <qwen:user-prompt-submit-context> 块。评审探针确认了该回归类:对无 payload 记录强制写入 systemPayload: {} 后,本文件全部 70 个测试仍然通过;恢复被删断言后则能捕获。— 建议修复:保留一个负向用例(见上方代码块)。

— qwen3.8-max via Qwen Code /review (v0.21.7)

return typeof value === 'string' ? value : undefined;
}

// Keep these in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE in Qwen core.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The sync comment names only the two tag constants, but the obligation covers the entire mirrored projection algorithm — isQwenUserPromptContextPart duplicates core's isUserPromptSubmitContextPartText (trim semantics, \n-padded prefix/suffix, body tag-rejection), and projectQwenUserRecordText duplicates projectUserTranscriptForDisplay's decision table (parts.length > 1 guard, hookContext-or-final-tag pairing evidence, authoritative-displayText rule, payload === undefined strip fallback). Desktop has no core dependency, so the duplication is structurally forced — but the comment understates the sync surface. — Failure scenario: a future core change to projection semantics (new tag variant, changed pairing-evidence rule, changed fallback) lands without a matching desktop edit; nothing in either workspace flags the divergence; desktop then renders the raw <qwen:user-prompt-submit-context> block in the transcript UI or drops user-visible text while CLI/VS Code/webui render correctly — desktop's own test asserts its own frozen copy, so both workspaces stay green.

Suggested change
// Keep these in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE in Qwen core.
// Keep the tag constants and the projection semantics (isQwenUserPromptContextPart / projectQwenUserRecordText) in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE, isUserPromptSubmitContextPartText, and projectUserTranscriptForDisplay in Qwen core.
中文说明

同步注释只点名了两个标签常量,但同步义务实际覆盖整个镜像的投影算法——isQwenUserPromptContextPart 复制了 core 的 isUserPromptSubmitContextPartText(trim 语义、带 \n 的前/后缀、body 内标签拒绝),projectQwenUserRecordText 复制了 projectUserTranscriptForDisplay 的决策表(parts.length > 1 守卫、hookContext 或末尾标签 part 的配对证据、权威的 displayText 规则、payload === undefined 的仅剥离回退)。desktop 不依赖 core,重复不可避免——但注释低估了同步面。— 失败场景:未来 core 投影语义变更(新标签变体、配对证据规则或回退策略变化)落地而 desktop 未同步;两个 workspace 都没有任何机制发现分歧;desktop 将在历史界面渲染原始 <qwen:user-prompt-submit-context> 块或丢失用户可见文本,而 CLI/VS Code/webui 渲染正常——desktop 自己的测试只断言自己的冻结副本,两个 workspace 都会保持绿色。

— qwen3.8-max via Qwen Code /review (v0.21.7)

* SPDX-License-Identifier: Apache-2.0
*/

// Keep these in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE in core.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same hazard as the desktop copy: this webui adapter is a third full implementation of core's projection algorithm (isHookContextPart mirrors isUserPromptSubmitContextPartText; getUserTranscriptDisplayText mirrors projectUserTranscriptForDisplay's decision table), but the sync comment names only the two tag constants. Webui imports only @qwen-code/sdk, so the duplication is structurally forced — but nothing keeps the copies aligned. — Failure scenario: a future core change to projection semantics lands without a matching webui edit; no test in either package flags it; webui then renders the raw <qwen:user-prompt-submit-context> block in the ChatViewer or drops user-visible text while CLI/VS Code render correctly. If the desktop comment fix lands without this one, this copy stays silently divergent.

Suggested change
// Keep these in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE in core.
// Keep the tag constants and the projection semantics (isHookContextPart / getUserTranscriptDisplayText) in sync with USER_PROMPT_SUBMIT_CONTEXT_OPEN/CLOSE, isUserPromptSubmitContextPartText, and projectUserTranscriptForDisplay in core.
中文说明

与 desktop 副本相同的隐患:该 webui 适配器是 core 投影算法的第三份完整实现(isHookContextPart 镜像 isUserPromptSubmitContextPartTextgetUserTranscriptDisplayText 镜像 projectUserTranscriptForDisplay 的决策表),但同步注释只点名了两个标签常量。webui 只依赖 @qwen-code/sdk,重复不可避免——但没有任何机制保持副本一致。— 失败场景:未来 core 投影语义变更落地而 webui 未同步;两个包的测试都不会报警;webui 将在 ChatViewer 中渲染原始 <qwen:user-prompt-submit-context> 块或丢失用户可见文本,而 CLI/VS Code 渲染正常。如果只修 desktop 注释而不修这里,这份副本会静默失同步。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +674 to +676
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This sentence is ambiguous and contradicts first-party behavior: notification/cron/mid-turn records can never carry the pairing evidence defined above (hooks never fire for Cron/Notification — verified in client.ts's hook gate), yet ACP replay and CLI resume substitute their displayText label unconditionally, while webui, insight DataProcessor, and session-reference keep model text. "Substitute X for Y" is also a classically inverted construction, so both readings are available to an implementer. — Failure scenario: a third-party/offline consumer (this block's stated audience) implementing the rule as written never sees evidence on cron/notification records, so it renders their model-bound parts — the raw <task-notification /> envelope or the full internal cron prompt — where first-party surfaces show the compact label; the opposite reading contradicts webui's policy. Either way, following the spec diverges from at least one first-party surface. — Suggested fix: separate the two regimes.

Suggested change
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.
cron, and mid-turn records can also have `displayText`, but those values are
compact display labels. Subtype-aware consumers render those records using the
display label; the pairing-evidence rule above governs only user-prompt records.
中文说明

该句存在歧义且与第一方行为矛盾:notification/cron/mid-turn 记录永远不可能携带上文定义的配对证据(client.ts 的 Hook 门控已核实 Cron/Notification 从不触发 Hook),但 ACP replay 与 CLI resume 会无条件用 displayText 标签替换正文,而 webui、insight DataProcessor、session-reference 保留模型正文。"substitute X for Y" 又是经典的可反向解读结构,实现者两种读法都成立。— 失败场景:按本段目标读者(第三方/离线消费者)照字面实现规则时,cron/notification 记录永远没有证据,于是渲染其模型正文——原始 <task-notification /> 封装或完整 cron 内部 prompt——而第一方界面显示的是紧凑标签;反向读法又与 webui 策略矛盾。无论哪种读法,照规范实现都会与至少一个第一方界面不一致。— 建议修复:把两种机制分开表述(见上方 suggestion)。

— qwen3.8-max via Qwen Code /review (v0.21.7)


# Core Mandates

- **UserPromptSubmit Context:** Text inside a \`<qwen:user-prompt-submit-context>\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new Core Mandate hardcodes the tag literal instead of deriving it from USER_PROMPT_SUBMIT_CONTEXT_OPEN in utils/transcript-records.ts; prompts.test.ts:82 asserts the same literal and every snapshot bakes it in — a literal-against-literal island. — Failure scenario: a future tag rename follows the constants everywhere (wrapping, projection, tests) except the system prompt; the shipped test stays green while the model receives hook-injected context inside the new tag with no mandate identifying it as non-user input, so a malicious UserPromptSubmit hook's additionalContext can be treated as the user's own instructions — the exact misattribution this PR exists to prevent. A review probe confirmed it: renaming only the two constants left prompts.test.ts green while the prompt named a tag the writer no longer writes. — Suggested fix: import the constant and interpolate:

import { USER_PROMPT_SUBMIT_CONTEXT_OPEN } from '../utils/transcript-records.js';
// in the mandate template:
- **UserPromptSubmit Context:** Text inside a \`${USER_PROMPT_SUBMIT_CONTEXT_OPEN}\` tag is model context added by a configured \`UserPromptSubmit\` hook, not user input.

(or at minimum have prompts.test.ts assert the sentence constructed from the constant, and update snapshots)

中文说明

新增的 Core Mandate 把标签字面量硬编码,而不是从 utils/transcript-records.tsUSER_PROMPT_SUBMIT_CONTEXT_OPEN 派生;prompts.test.ts:82 断言的也是同一字面量,所有快照同样固化了它——形成一个「字面量对字面量」的孤岛。— 失败场景:未来重命名标签时,常量驱动的各处(包裹、投影、测试)都会跟随,唯独系统提示词不跟随;现有测试仍然绿色,但模型会收到包在新标签里的 Hook 注入内容,而提示词中没有任何条款说明它不是用户输入——恶意 UserPromptSubmit Hook 的 additionalContext 就可能被当作用户本人的指令,正是本 PR 要防止的错误归因。评审探针已确认:只重命名两个常量,prompts.test.ts 依然通过,而提示词描述的标签已不是写入端实际使用的标签。— 建议修复:导入常量并插值(见上方代码块),或至少让 prompts.test.ts 断言由常量构造出的句子并更新快照。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines 644 to 647
USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG,
wrapUserPromptSubmitContext,
isUserPromptSubmitContextPartText,
stripTrailingUserPromptSubmitContextPart,
} from './hooks/user-prompt-submit-context.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The rewires in this PR removed the last production consumer of stripTrailingUserPromptSubmitContextPart (resume history and ACP replay now route through projectUserTranscriptForDisplay), but the function remains publicly exported here and via the ./userPromptSubmitContext subpath with zero production callers. Worse, its decision table already differs from the wired projection today: the exported strip removes a trailing tagged part by part shape alone, while the projection strips only when systemPayload is absent — a record with any payload object plus a trailing tag is stripped by the export but kept raw by every wired display surface. — Failure scenario: a future change to the strip policy lands in projectUserTranscriptForDisplay (which every consumer calls) while the exported duplicate and its passing tests silently diverge; the green tests give false confidence that strip behavior is still covered, and a new caller (e.g. an extension author) importing the public variant gets semantics matching no wired consumer. — Suggested fix: delete the helper, this export, and its describe block in user-prompt-submit-context.test.ts — or, if deliberately kept as public API, add a comment naming that contract and reconcile its strip gate with the projection's.

中文说明

本 PR 的改造移除了 stripTrailingUserPromptSubmitContextPart 的最后一批生产消费端(resume history 与 ACP replay 现在都经过 projectUserTranscriptForDisplay),但该函数仍通过此处 barrel 及 ./userPromptSubmitContext 子路径公开导出,且没有任何生产调用者。更糟的是它的决策表与线上投影今天就已不同:导出版仅凭 part 形状删除尾部标签 part,而投影只在 systemPayload 缺失时才剥离——带任意 payload 对象且有尾部标签的记录,导出版会剥离,而所有线上展示面都会保留原文。— 失败场景:未来剥离策略的修改落在 projectUserTranscriptForDisplay(所有消费端实际调用的函数)上,而这份公开副本和它通过的测试静默失同步;绿色测试制造「剥离行为仍有覆盖」的假象,新调用者(如扩展作者)导入这个公开变体得到的语义与任何线上消费端都不一致。— 建议修复:删除该辅助函数、此导出以及 user-prompt-submit-context.test.ts 中对应的 describe 块——或者,若确有意保留为公开 API,请加注释说明该契约,并让其剥离条件与投影保持一致。

— qwen3.8-max via Qwen Code /review (v0.21.7)

.flatMap((record) => record.message?.parts ?? [])
.flatMap((record) => {
if (type !== 'user') return record.message?.parts ?? [];
const projection = projectUserTranscriptForDisplay(record);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The changed extractText fallback branch (projection.parts — tag-strip and legacy passthrough) has no test at the conversation-branches level; the only new integration test covers the displayText branch (its record carries a full payload). — Failure scenario: a future edit that keeps the displayText branch but reverts the fallback to record.message?.parts ?? [] passes every test in this file — a review probe confirmed it: with the regression in place the full suite still passed 21/21 while the raw <qwen:user-prompt-submit-context> block leaked into firstUserTextAfterBranchPoint/lastUserText branch summaries shown to users, for payload-less records with a trailing tag part (the third-party shape the projection's docstring explicitly designs for). — Suggested fix: add a case with a user record whose parts are [{text: 'user text'}, {text: '<qwen:user-prompt-submit-context>\nhook-only context\n</qwen:user-prompt-submit-context>'}] and no systemPayload, asserting the branch summaries equal 'user text' and contain no tag content.

中文说明

extractText 改动的回退分支(projection.parts——标签剥离与旧式直通路径)在 conversation-branches 层级没有任何测试;唯一的新集成测试只覆盖 displayText 分支(其记录带完整 payload)。— 失败场景:未来某次修改保留了 displayText 分支、却把回退改回 record.message?.parts ?? [],本文件所有测试仍会通过——评审探针已确认:回归存在时完整套件仍 21/21 通过,而对带尾部标签 part 的无 payload 记录(投影 docstring 明确支持的第三方形状),原始 <qwen:user-prompt-submit-context> 块会泄漏进展示给用户的分支摘要 firstUserTextAfterBranchPoint/lastUserText。— 建议修复:新增一个用例,用户记录 parts 为 [{text: 'user text'}, {text: '<qwen:user-prompt-submit-context>\nhook-only context\n</qwen:user-prompt-submit-context>'}] 且无 systemPayload,断言分支摘要等于 'user text' 且不含标签内容。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines 551 to +552
const content: string[] = [];
for (const part of record.message?.parts ?? []) {
const parts = projection?.parts ?? record.message?.parts ?? [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The projection fallback in evidenceContent (and the independently wired twin in evidencePreview below) is untested at the goal-evidence level — the new goal tests cover only the displayText branch and the mid-turn passthrough. — Failure scenario: a future edit that keeps the displayText branch but reverts this fallback to record.message?.parts ?? [] passes every test in this file — a review probe confirmed it: with the regression applied to both functions the suite still passed 18/18, while for a payload-less real_user record with a trailing complete tag part the raw <qwen:user-prompt-submit-context> block flowed into cited content delivered to the Goal verifier as user_input-kind evidence, contradicting the Core Mandate this PR adds. — Suggested fix: add a goal-evidence test for a real_user record with no systemPayload and a trailing tagged part, asserting validated content and catalog preview equal the user text and contain no tag block.

中文说明

evidenceContent 中的投影回退(以及下方 evidencePreview 中独立接线的孪生分支)在 goal-evidence 层级没有测试——新增的 goal 测试只覆盖 displayText 分支与 mid-turn 直通。— 失败场景:未来某次修改保留 displayText 分支、却把该回退改回 record.message?.parts ?? [],本文件所有测试仍会通过——评审探针已确认:对两个函数同时施加该回归后套件仍 18/18 通过,而对带完整尾部标签 part 的无 payload real_user 记录,原始 <qwen:user-prompt-submit-context> 块会流入作为 user_input 类证据交给 Goal verifier 的引用内容,与本 PR 新增的 Core Mandate 相悖。— 建议修复:新增 goal-evidence 测试:无 systemPayload、带尾部标签 part 的 real_user 记录,断言校验内容与 catalog preview 等于用户文本且不含标签块。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +584 to 587
const parts = projection?.parts ?? record.message?.parts ?? [];
for (const part of parts) {
if (part.thought !== true && typeof part.text === 'string') {
append(part.text);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same untested projection fallback as in evidenceContent above — evidencePreview is wired independently, so a partial revert can break one without the other; the existing goal tests pin neither fallback (a review probe reverted both and the suite still passed 18/18). — Failure scenario: a revert of just this fallback leaks the raw <qwen:user-prompt-submit-context> block into the catalog preview shown to the Goal worker for payload-less records with a trailing tag part. — Suggested fix: the same single test (payload-less tagged real_user record) can pin both functions by asserting on both the validated content and the preview.

中文说明

与上方 evidenceContent 相同的未测试投影回退——evidencePreview 是独立接线的,因此部分回退可能只破坏其中一个而另一个正常;现有 goal 测试对两个回退都没有钉住(评审探针把两者同时回退后套件仍 18/18 通过)。— 失败场景:只回退这一处,就会让带尾部标签 part 的无 payload 记录在展示给 Goal worker 的 catalog preview 中泄漏原始 <qwen:user-prompt-submit-context> 块。— 建议修复:同一个测试(无 payload、带标签 part 的 real_user 记录)可以同时钉住两个函数——同时断言校验内容与 preview。

— qwen3.8-max via Qwen Code /review (v0.21.7)

Comment on lines +666 to +668
projection carried by `submitted_prompt`; ACP, headless, `serve`, SDK, remote
input, and other paths without that provenance record the expanded pre-hook
prompt instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This overclaims the ACP/serve/SDK record shape. The spec says every UserQuery with added hook context preserves the tagged part and adds {displayText, hookContext}, but ACP-session turns record the raw pre-expansion input BEFORE the hook fires (Session.ts:3178, the only recordUserMessage call in that path) and send through GeminiChat.sendMessageStream directly, bypassing GeminiClient.sendMessageStream — the only place userPromptRecordPayload is written. Verified at the reviewed commit: headless genuinely matches the claim, TUI matches via submitted_prompt, but an ACP/serve/SDK JSONL user record has neither systemPayload nor tagged part. — Failure scenario: in a serve/ACP/SDK session whose hook injects additionalContext, the model sees the hook context but the JSONL record holds only the raw input text, while this spec tells offline/third-party consumers both are present — a consumer built on the spec finds no provenance on ACP/serve-originated sessions and misattributes or discards those turns. — Suggested fix: scope the sentences to the surfaces that write the payload, or extend the ACP path to write it.

Suggested change
projection carried by `submitted_prompt`; ACP, headless, `serve`, SDK, remote
input, and other paths without that provenance record the expanded pre-hook
prompt instead.
projection carried by `submitted_prompt`, and headless runs record the
expanded pre-hook prompt the same way. ACP, `serve`, and SDK turns record the
raw pre-hook input before the hook fires and carry no display provenance.
中文说明

此处对 ACP/serve/SDK 的记录形状表述过度。规范声称每个带 Hook 上下文的 UserQuery 都会保留标签 part 并附加 {displayText, hookContext},但 ACP 会话轮次在 Hook 触发之前就已记录原始未展开输入(Session.ts:3178,该路径唯一的 recordUserMessage 调用),且直接经 GeminiChat.sendMessageStream 发送,绕过了 GeminiClient.sendMessageStream——写入 userPromptRecordPayload 的唯一位置。已在被审提交上核实:headless 与描述相符,TUI 通过 submitted_prompt 相符,但 ACP/serve/SDK 的 JSONL 用户记录既无 systemPayload 也无标签 part。— 失败场景:在 Hook 注入 additionalContext 的 serve/ACP/SDK 会话中,模型看到了 Hook 上下文,但 JSONL 记录只有原始输入文本,而本规范告诉离线/第三方消费者两者都存在——按规范实现的消费者在 ACP/serve 来源的会话中找不到任何来源信息,会错误归因或直接丢弃这些轮次。— 建议修复:把表述限定到实际写入 payload 的界面,或扩展 ACP 路径使其写入 payload(见上方 suggestion,为前一种方案)。

— qwen3.8-max via Qwen Code /review (v0.21.7)

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — PR #7948

Verified on a real local environment: both arms built from source and driven through the real qwen CLI with a real UserPromptSubmit hook, against a mock OpenAI-compatible provider for determinism. No mocked transcript fixtures were used for the A/B — every projection below was run on a session actually recorded by the CLI.

Verdict: LGTM — recommend merge. The fix is real, reproducible, and backward compatible. One pre-existing residual gap is noted below (not a regression; suggested as a follow-up).

Environment

PR head d66130b82
Merge-base 3edecac11 (origin/main at fetch time: 9e1b1eb49)
Arms qc-7948-pr @ PR head · qc-7948-base @ merge-base — both npm run build for core / acp-bridge / cli
Runner Linux, Node 22.x, CLI reports v0.21.7
Hook shell script returning {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"SECRET-HOOK-CONTEXT current branch is main; 3 files changed"}}
Model zero-dep mock OpenAI server (/v1/chat/completions), fixed reply — removes model variance

1. Recorded JSONL — the hook fired for real, model history is unchanged

The hook's stdin capture confirms a genuine UserPromptSubmit dispatch (hook_event_name, session_id, transcript_path, prompt: "user prompt"). The recorded user record:

recorded JSONL

message.parts is byte-identical between the two arms — resume still replays exactly what the model saw. The PR only adds the paired hookContext provenance next to displayText.

2. Consumer matrix — this is what the PR actually buys

One real recorded session, replayed through each consumer's real built module in each arm. LEAK = the hook context is presented as if the user had typed it.

consumer matrix

Seven surfaces are fixed. Three were already clean on the merge-base (--resume, ACP replay, /export) and stay clean — the refactor causes no regression there. /export was checked separately because the PR does not touch it: it consumes ACP session updates, so it is covered transitively.

The sharpest one is SessionReferenceService (@session references). Its output is injected into a different session's model prompt, so on the merge-base the hook context from session A is replayed into session B attributed as User: — and the derived session title was polluted too:

merge-base:  --- Referenced session "user prompt<qwen:user-prompt-submit-context>" (slimmed, read-only) ---
             User: user prompt<qwen:user-prompt-submit-context>
             SECRET-HOOK-CONTEXT current branch is main; 3 files changed
             </qwen:user-prompt-submit-context>

PR #7948:    --- Referenced session "user prompt" (slimmed, read-only) ---
             User: user prompt
             Assistant: MOCK-ANSWER-OK

The same polluted title showed up in the VS Code panel (getSessionTitle returned "user prompt\n<qwen:user-prompt-submit-context>\nSECR…" on the merge-base, "user prompt" on the PR).

Backward compatibility confirmed: PR-arm consumers reading merge-base-written records (displayText only, no hookContext) are clean on all 10 surfaces — the hasFinalHookContextPart fallback works on already-released data.

3. Real TUI resume

Session submitted through the interactive TUI (so submittedPrompt provenance is exercised), then resumed with --resume:

TUI resume

4. Cross-copy consistency

The projection logic exists in four places: core (projectUserTranscriptForDisplay), webui and desktop (hand-rolled copies, since neither package depends on core), and VS Code (thin wrapper over core). I ran all copies over 12 edge-case records — paired metadata, empty displayText, released displayText-only, synthetic-record field collision, tag-only, systemPayload: null, legacy bare parts, single tag-only part, hookContext without displayText, tag not last, image + paired metadata, forged nested tag.

All copies agree on every decision in all 12 cases. The only differences are each product's pre-existing join separator ('' / '\n\n' / '\n'), not the visibility decision.

Also worth noting: the new matcher is stricter than the one it replaces. Given a forged block whose body contains a nested open tag, the merge-base matcher returns true (and would strip user-authored text); the PR's returns false. Since getAdditionalContext() escapes </>, a genuine hook context can never contain the tag, so this is correct hardening.

5. Test suites and repo health

Suite Result
core (8 files from the PR's plan) 578 passed
cli (3 files) 154 passed
acp-bridge 26 passed
webui 11 passed
vscode-ide-companion 15 passed
desktop (bun) not run — desktop workspace deps are not installed in this environment

For the desktop suite I verified the shipped code path instead: projectQwenUserRecordText and its helpers were extracted verbatim from qwen-agent.ts and run against the real record — merge-base logic returns "user prompt\n\n<qwen:user-prompt-submit-context>\nSECRET-HOOK-CONTEXT …", the PR's returns "user prompt". Also confirmed the new user branch keeps the original '\n\n' join and text-only semantics, so non-user records are unaffected.

git merge of the PR head into the current origin/main is conflict-free, and CI is green (no failing checks).

6. Findings

F1 — Residual gap (pre-existing, not a regression): two qwen serve consumers still concatenate raw parts.

residual gap

  • packages/cli/src/serve/live/realtime-startup-context.ts:127textOf() is applied to records filtered by record.type === 'user' && provenance real_user, and the result is built into the live session's startup context, i.e. it goes back to the model. Reached from live-session-coordinator.ts:509.
  • packages/cli/src/serve/live/live-task-service.ts:133textOf() becomes the userMessage item content in the live-task API. Reached from serve/server.ts:1357.

Both are partToString(record.message?.parts ?? []).trim() and both still emit the hook context, verified against the same real record. They leak on the merge-base too, so this is not something the PR breaks — but it is the same consumer class the PR fixes everywhere else, and SessionReferenceService (fixed here) is the direct analogue of the first one. Suggest a follow-up routing both through projectUserTranscriptForDisplay; I would not hold this PR for it.

F2 — Nit: four copies of the tag constants with no sync test. The keep in sync comments in webui/src/adapters/userTranscriptDisplay.ts and desktop/.../qwen-agent.ts are the only thing preventing drift. The duplication itself is justified (webui depends only on @qwen-code/sdk, and pulling core into a browser lib would be worse), but a shared fixture table asserted by all three packages' tests would make drift fail loudly rather than silently. My 12-case differential passes today, so this is preventative only.

F3 — Note, intentional: displayText semantics differ between the TUI and -p. displayText: submittedPrompt ?? promptText means an @file turn records the raw typed text in the TUI but the fully expanded text under qwen -p (no submittedPrompt there). Confirmed with real runs. Because the projection drops all text parts whenever displayText is present, the same feature therefore yields slightly different transcript content depending on where the turn came from. This favours display cleanliness and only applies when a hook is configured, so I read it as acceptable — flagging so it is a deliberate choice rather than a surprise.

F4 — Note: hookContext present without displayText falls through unstripped. All four copies agree, and the writer never produces that shape, so it is unreachable in practice. Mentioned only for completeness.

中文完整版

本地验证报告 — PR #7948

真实本地环境完成验证:两个 arm 均从源码构建,通过真实 qwen CLI + 真实 UserPromptSubmit hook 驱动,模型侧使用 mock OpenAI 兼容服务以保证确定性。A/B 中没有使用任何伪造的 transcript fixture —— 下面所有投影结果都跑在 CLI 真实写出的会话记录上。

结论:LGTM,建议合入。 修复真实、可复现、且向后兼容。下面记录了一个既有的遗留缺口(不是本 PR 引入的回归,建议作为 follow-up)。

环境

PR head d66130b82
Merge-base 3edecac11(拉取时 origin/main9e1b1eb49
两个 arm qc-7948-pr @ PR head · qc-7948-base @ merge-base —— 均对 core / acp-bridge / cli 执行 npm run build
运行环境 Linux,Node 22.x,CLI 自报 v0.21.7
Hook shell 脚本返回 {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"SECRET-HOOK-CONTEXT current branch is main; 3 files changed"}}
模型 零依赖 mock OpenAI server(/v1/chat/completions),固定回复,消除模型波动

1. 记录的 JSONL —— hook 真实触发,模型历史保持不变

hook 的 stdin 捕获证明这是一次真实的 UserPromptSubmit 派发(含 hook_event_namesession_idtranscript_pathprompt: "user prompt")。写入的 user 记录见上方第一张截图。

两个 arm 的 message.parts 逐字节一致 —— resume 仍然重放模型实际看到的内容。本 PR 只是在 displayText 旁边补上了成对的 hookContext 来源信息。

2. 消费端矩阵 —— 这才是本 PR 的真正价值

同一个真实录制的会话,分别送进两个 arm 各自真实构建出的消费端模块LEAK 表示 hook 上下文被当成用户亲自输入的内容展示出来(见上方第二张截图)。

七个消费端被修复。三个在 merge-base 上本来就是干净的(--resume、ACP replay、/export),本 PR 之后依然干净 —— 重构没有在这些路径上引入回归。/export 是我单独验证的,因为本 PR 并没有改它:它消费的是 ACP session update,所以是被间接覆盖的。

其中最严重的是 SessionReferenceService@session 引用)。它的输出会被注入到另一个会话的模型 prompt 里,所以在 merge-base 上,会话 A 的 hook 上下文会被以 User: 的身份重放进会话 B —— 并且派生出的会话标题也被污染了:

merge-base:  --- Referenced session "user prompt<qwen:user-prompt-submit-context>" (slimmed, read-only) ---
             User: user prompt<qwen:user-prompt-submit-context>
             SECRET-HOOK-CONTEXT current branch is main; 3 files changed
             </qwen:user-prompt-submit-context>

PR #7948:    --- Referenced session "user prompt" (slimmed, read-only) ---
             User: user prompt
             Assistant: MOCK-ANSWER-OK

同样被污染的标题也出现在 VS Code 面板里:merge-base 上 getSessionTitle 返回 "user prompt\n<qwen:user-prompt-submit-context>\nSECR…",本 PR 返回 "user prompt"

向后兼容已确认: 用 PR arm 的消费端去读 merge-base 写出的记录(只有 displayText、没有 hookContext),10 个消费端全部干净 —— 说明 hasFinalHookContextPart 这条回退路径在已发布的数据上是有效的。

3. 真实 TUI Resume

先通过交互式 TUI 提交(这样才会走到 submittedPrompt 这条来源),再用 --resume 恢复,见上方第三张截图。

4. 多份拷贝的一致性

投影逻辑一共存在于四处:core(projectUserTranscriptForDisplay)、webui 与 desktop(各自手写的拷贝,因为这两个包都不依赖 core)、以及 VS Code(对 core 的薄封装)。我用 12 个边界用例把所有拷贝都跑了一遍 —— 成对 metadata、空 displayText、已发布的仅 displayText、合成记录字段冲突、仅带 tag、systemPayload: null、旧版裸 parts、单个仅 tag 的 part、有 hookContext 但没有 displayText、tag 不在末尾、图片 + 成对 metadata、伪造的嵌套 tag。

12 个用例中所有拷贝的判定完全一致。 唯一的差异是各产品原有的拼接分隔符('' / '\n\n' / '\n'),而不是可见性判定本身。

另外值得一提:新的匹配器比它替换掉的那个更严格。对于 body 中含有嵌套 open tag 的伪造块,merge-base 的匹配器会返回 true(从而会误删用户自己写的文本),本 PR 返回 false。由于 getAdditionalContext() 会转义 </>,真实的 hook 上下文永远不可能包含该 tag,所以这是正确的加固。

5. 测试套件与仓库健康度

套件 结果
core(PR 计划中的 8 个文件) 578 通过
cli(3 个文件) 154 通过
acp-bridge 26 通过
webui 11 通过
vscode-ide-companion 15 通过
desktop(bun) 未运行 —— 本环境未安装 desktop workspace 依赖

desktop 套件我改用另一种方式验证了实际发布的代码路径:把 qwen-agent.ts 中的 projectQwenUserRecordText 及其依赖函数原样抽出,对真实记录运行 —— merge-base 的逻辑返回 "user prompt\n\n<qwen:user-prompt-submit-context>\nSECRET-HOOK-CONTEXT …",本 PR 返回 "user prompt"。同时确认新增的 user 分支保留了原有的 '\n\n' 拼接与仅取 text 的语义,因此非 user 记录不受影响。

把 PR head 合并进当前的 origin/main 无冲突,CI 全绿(无失败项)。

6. 发现的问题

F1 —— 遗留缺口(既有问题,非本 PR 回归):两个 qwen serve 消费端仍在直接拼接原始 parts。(见上方第四张截图)

  • packages/cli/src/serve/live/realtime-startup-context.ts:127 —— textOf() 作用在按 record.type === 'user' && provenance real_user 过滤出来的记录上,结果会被组装进 live session 的启动上下文,也就是会回到模型。调用入口:live-session-coordinator.ts:509
  • packages/cli/src/serve/live/live-task-service.ts:133 —— textOf() 会成为 live-task API 中 userMessage 项的内容。调用入口:serve/server.ts:1357

两处都是 partToString(record.message?.parts ?? []).trim(),用同一条真实记录验证,两处仍然会泄漏 hook 上下文。它们在 merge-base 上同样泄漏,所以并不是本 PR 弄坏的 —— 但它们属于本 PR 在其他地方已经修复的同一类消费端,而且本 PR 修好的 SessionReferenceService 正是第一处的直接对应物。建议后续用 projectUserTranscriptForDisplay 统一改造;不建议因此阻塞本 PR。

F2 —— Nit:tag 常量有四份拷贝且没有同步测试。 目前唯一防止漂移的只有 webui/src/adapters/userTranscriptDisplay.tsdesktop/.../qwen-agent.ts 里的 keep in sync 注释。重复本身是合理的(webui 只依赖 @qwen-code/sdk,把 core 拉进浏览器库反而更糟),但如果有一份共享 fixture 表、由三个包的测试共同断言,漂移就会显式失败而不是静默发生。我的 12 用例差分目前是通过的,所以这只是预防性建议。

F3 —— 说明(属于有意设计):displayText 在 TUI 与 -p 下语义不同。 displayText: submittedPrompt ?? promptText 意味着一次 @file 轮次在 TUI 下记录的是用户原始输入,而在 qwen -p 下记录的是完全展开后的文本(该路径没有 submittedPrompt)。已用真实运行确认。又因为只要存在 displayText,投影就会丢掉所有 text part,所以同一个功能会因为轮次来源不同而产生略有差异的 transcript 内容。这个取舍偏向展示的干净度,且只在配置了 hook 时生效,我认为可以接受 —— 在此标注出来,是为了让它成为一个明确的选择而不是日后的意外。

F4 —— 说明:只有 hookContext 而没有 displayText 时不会剥离。 四份拷贝行为一致,且写入端永远不会产生这种形状,实际不可达。仅为完整性记录。

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge August 7, 2026 15:55
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 118 passed · 0 failed · 118 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:118 通过 · 0 失败 · 118 总计

Verification report

PR 7948 Deep Verification — fix(core): separate hook context from transcript display

Verdict: merge-ready — 118 scripted assertions executed, 118 passed, 0 failed.
Verified head OID: d66130b82960a2cf7c19fe4d07dceb71733832ec (merge-ref checkout: HEAD 1cc4aaf, base tip 3edecac).

中文摘要

结论:merge-ready。118 项脚本化断言全部通过,0 失败。

  • A/B 结论(见下表「Writer A/B」与「Consumer flip」):在真实 CLI 端到端场景(假 OpenAI 模型 + UserPromptSubmit hook 注入 hook-only context)下,head 录制的 JSONL 用户记录保留完整模型 parts(user prompt + 带标签上下文 part),并写入成对 systemPayload {displayText, hookContext};base 只写 displayText(对照单元格成立)。模型实际收到的 message 在两臂逐字节一致——本 PR 只改持久化来源信息与展示投影,不改模型输入。消费端 A/B:insight DataProcessor、WebUI、VS Code 三个消费端在 base 上共 15 个泄漏单元格(把 hook 上下文显示为用户输入)在 head 上全部变为干净;ACP replay 在 base 已干净(第一轮修复),语义差异仅为文档化的空 displayText 与单 part 保守保留。9 个兼容性 fixture(成对/旧版仅 displayText/仅标签/legacy/空 displayText/用户自造标签文本/单标签 part/图片+文本/legacy 裸注入)在 head 上 9/9 符合设计语义。
  • Findings:两条非阻塞完整性观察——变异矩阵中 M3/M4 两个守卫存活(行为已用真实 dist 验证为正确且方向保守,但无任何测试钉住,属覆盖缺口而非缺陷);WebUI 与 Desktop 各自复制了一份标签匹配器(有意为之、带同步注释,WebUI 副本已经行为验证,Desktop 副本因环境无法执行)。
  • 未覆盖范围:Desktop bun 套件在容器内不可运行(date-fns 未安装;base 树 A/A 对照同样失败,证明为环境问题);真实 TUI 交互式 resume 的界面渲染未端到端演练(由 resumeHistoryUtils 定向套件 + 投影矩阵覆盖);逐 commit 归因不可达(depth-2 浅克隆本地仅见合并提交,与元数据 22 个 commit 不符,验证对象为聚合 diff);npm run test:integration:no-ak:sandbox:none 全量集成未跑(超出定向范围,PR 自身 CI 覆盖)。

Central claim + A/B

Central claim: when a UserPromptSubmit hook injects additionalContext on a UserQuery, the recorded JSONL user entry retains the exact model-bound parts (user text + one tagged <qwen:user-prompt-submit-context> part) while persisting paired systemPayload {displayText, hookContext} provenance, and every display consumer (CLI resume/insight, WebUI, VS Code, Desktop, ACP replay, goal evidence, session references, branches) projects only the pre-hook prompt — never the hook context.

Secondary claims: (1) compatibility projection semantics across record eras (paired metadata authoritative incl. empty displayText; released displayText-only records pair via a complete final tagged part; tag-only records strip only that final part; legacy shapes untouched; non-text parts retained); (2) telemetry span attributes and auto-memory recall use the pre-hook prompt.

Note on the control: the base tip already contains the first iteration of this fix lineage (tag wrapping, displayText-only payload, CLI-resume + ACP-replay projections). The flips this PR itself carries are the hookContext provenance field, the submittedPrompt-preferring displayText, and the consumers base had not fixed (insight DataProcessor, WebUI, VS Code, Desktop, goal evidence, session references, branches). The A/B below measures exactly those.

Writer A/B (real CLI E2E — 01-writer-ab.mjs, witness: 01-writer-consumer-ab-head-vs-base.png)

Scenario: node packages/cli/dist/index.js --prompt "user prompt" --yolo against a loopback fake OpenAI server, with a command hook returning additionalContext: "hook-only context"; session JSONL read back from $QWEN_HOME.

cell environment observable oracle result
head writer HEAD dist CLI recorded user record parts ["user prompt", TAGGED]; systemPayload {displayText:"user prompt", hookContext:"hook-only context"}
base writer (control) base-tree dist (3edecac, rebuilt, realpath-verified) recorded user record parts identical; systemPayload {displayText:"user prompt"}hookContext absent (predicted pre-PR shape)
model-bound, both arms fake server request capture last user message parts byte-identical across arms after scratch-path normalization: ["user prompt", TAGGED]

17/17 scripted writer assertions passed (05-writer-assertions.mjs), including the control cell asserting base lacks hookContext.

Consumer flip matrix (real consumers, both arms — 02-consumer-ab.mjs)

9 fixtures × 5 consumers. Leak-shape fixtures (F1 paired, F2 displayText-only era, F3 tag-only, F5 empty displayText, F8 image+text):

consumer base leaks (of 5) head leaks (of 5) flip
CLI insight DataProcessor (real dist) 5/5 0/5 5 fixed
WebUI adaptJSONLMessages (real source) 5/5 0/5 5 fixed
VS Code record text (head: qwenRecordToText; base: faithful replica of deleted contentToText) 5/5 0/5 5 fixed
ACP transcript replay (real dist) 0/5 0/5 already clean on base (first-round fix); documented divergences below
core projectUserTranscriptForDisplay absent on base (new) 9/9 compat cells as designed

Head-arm compat details, all asserted: empty displayText is authoritative ("", no fallback to parts); user-authored tag-like prose is never matched; a sole tagged part with no metadata is preserved; legacy bare-injection records keep their text; image parts survive alongside displayText. Base control cells all reproduced the predicted leak/consumers-absent behavior (36/36 base assertions passed).

Documented intentional divergences (not regressions): on F5 (synthetic empty displayText + non-empty parts) base ACP shows the stripped parts while head shows nothing — "displayText is authoritative even when empty" is pinned by the PR's own unit test and the shape is not writer-producible; both arms preserve a single tagged part (F7) via the same length <= 1 guard lineage.

Witness for both tables: 01-writer-consumer-ab-head-vs-base.png.

Corrections

None required (no prior-round report present; no inaccurate bot claims in scope).

Findings

Ordered by severity. No blocking findings.

1. Two projection guards survive mutation — coverage gaps, behavior verified correct (Info, non-blocking)

Mutation matrix: 11 mutants over the PR's production hunks, including one positive control (PC1, whole projection removed); baselines green (17/17, 318/318, 70/70); 9/11 killed, incl. the positive control (4 tests red), proving the harness and suite are live (02-mutation-matrix.png).

mutant guard transcript-records suite cli+acp consumer suites (alias core src) classification
M1 drop parts.length > 1 sole tagged part preserved killed pinned
M2 drop final-tag pairing evidence displayText-only era pairing killed pinned
M3 drop payload === undefined on tag-only strip strip only metadata-free records survived survived coverage gap
M4 drop nested-tag body check forged/nested tag rejection survived survived coverage gap
M5 empty displayText truthy fallback empty-displayText authority killed pinned
M6 keep text parts with displayText text-part omission killed (2 tests) pinned
M7 matcher without \n shape exact wrapped shape killed pinned
PC1 projection passthrough positive control killed (4 tests) live
C1 telemetry post-hook prompt pre-hook span attribute killed (2 tests) pinned
C2 memory recall post-hook prompt pre-hook recall killed (2 tests) pinned
R1 drop systemPayload persistence provenance written to JSONL killed (2 tests) pinned

Survivor classification (both are coverage gaps, not dead code and not defects):

  • M3: the clause decides outcomes for records carrying a non-user-prompt systemPayload (e.g. {}) plus a complete final tagged part — head keeps the tagged part visible (driven through the real dist: {parts:[user, tagged], displayText: undefined}, conservative keep); the mutant would strip it. The writer never produces that shape, and the conservative direction matches the documented rule ("metadata-free records" only), but nothing asserts it.
  • M4: the body re-tag check rejects forged parts like OPEN…OPEN…CLOSE…CLOSE. Unreachable via this writer (getAdditionalContext() escapes </> — verified in types.ts), reachable only via hand-written third-party transcripts; no fixture exercises it.

Completeness reporting only — per the matrix both guards are correct and conservative; suggested follow-up fixtures (a systemPayload: {} + trailing-tag record; a nested-tag forged part) are named in the table above.

2. Tag matcher exists in three copies (Info, non-blocking)

Core holds the canonical matcher (utils/transcript-records.ts, Node-free subpath used by acp-bridge); WebUI (adapters/userTranscriptDisplay.ts) and Desktop (qwen-agent.ts) each carry a hand-copied matcher under "keep in sync" comments. This is the PR's disclosed tradeoff (browser bundles avoid the core barrel). I verified the WebUI copy behaviorally through the consumer matrix (F6/F7 semantics identical to core) and the Desktop copy by inspection (logic byte-equivalent); the Desktop suite that pins it could not run here (see Not covered). Drift risk is the residual cost; no action required for this PR.

Not covered

  • Desktop bun suite (qwen-agent-slash-history.test.ts): not runnable in this container — the desktop bun workspace is not installed (Cannot find module 'date-fns/locale/en-US'). Proven environmental via A/A control: the identical command on the base tree fails with the identical error. The Desktop consumer change is therefore covered only by inspection + the identical-logic WebUI arm.
  • Live TUI resume rendering (reviewer plan step 4's CLI leg): projection logic covered by the resumeHistoryUtils gate (154-test cli suite incl. the PR's new fixtures) and the consumer matrix; no interactive TUI session was driven.
  • Per-commit attribution: the depth-2 checkout exposes only the merge commit locally (git rev-list HEAD^1..HEAD^2 = 1) while the metadata lists 22 commits — verification is of the aggregate HEAD^1..HEAD diff.
  • Full integration lanes (test:integration:no-ak:sandbox:none, credentialed suites) and repo-wide test sweep: out of scope; PR CI covers them. The focused suites run here are the ones the diff touches.
  • Windows/Linux-specific paths: nothing OS-specific in the diff; N/A in container either way.
  • The ACP-replay arm's F5 divergence from base (see table) is a designed semantic change, not covered by a dedicated E2E — pinned by the PR's unit test uses display metadata even when the display text is empty.

Methodology

One container (node:22-bookworm, Node v22.23.2), PR code executed freely. Writer A/B: 01-writer-ab.mjs drove each tree's compiled CLI headlessly against an in-harness fake OpenAI server capturing every request body; hooks configured via project settings.json under a scratch QWEN_HOME; JSONL located under $QWEN_HOME/projects/<slug>/chats/. Base control: git worktree at HEAD^1 (3edecac) rebuilt (core, cli, acp-bridge) against the shared root node_modules (PR leaves lockfile untouched — dependency-pure control); internal @qwen-code/* links re-pointed via a sparse tmp/base-tree/node_modules overlay, and every harness dependency realpath asserted into the base tree before trust (readlink -ftmp/base-tree/packages/core|acp-bridge). Consumer A/B: 02-consumer-ab.mjs (tsx) fed 9 fixtures through real dist/source consumers per arm with containment oracles; base VS Code arm uses a faithful replica of the deleted private contentToText (byte-identical at base in both deleted copies). Mutation matrix: 03-mutation-matrix.mjs applied/restored single-point mutants around vitest runs; survivors re-tested against aliased consumer suites (03b-survivor-consumers.mjs) and driven through the real dist. Gates: focused suites per package + root typecheck. Raw logs per cell live in logs/; harness scripts in this directory.

Evidence images

01-writer-consumer-ab-head-vs-base

02-mutation-matrix

03-focused-gates-green

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot's only review on d66130b82960a2cf7c19fe4d07dceb71733832ec is a COMMENTED one, which carries no vote — so it has no verdict of its own on this commit, and main needs two approving reviews: an approval left by another account is a separate vote and does not count as the bot's own. Two different things look like this, and the stage-3 comment above says which: the triage skill deferring on purpose at 3/5 — a fork refactor hitting the approval guardrail, or a core change escalated for maintainer awareness, both normal outcomes — or an earlier approval that a push dismissed, leaving only the comment behind, which needs a fresh review.

⚠️ 机器人在 d66130b82960a2cf7c19fe4d07dceb71733832ec 上唯一的评审是 COMMENTED不带票 —— 因此它在该 commit 上没有自己的裁决,而 main 需要两个批准(其他账号的批准是另一张票)。有两种情况长这样,上方的 stage-3 评论会说明是哪一种:triage skill 在 3/5 时有意 defer(fork refactor 命中审批护栏,或核心改动被升级交由维护者把关,两者都是正常结果);或者更早的批准被一次推送作废、只剩下这条评论,此时需要重新评审。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, no blockers. Verified against merged #7956: this is its documented follow-up rollout, not a duplicate — main only has the single-field {displayText?} payload; the hookContext field, the trust-rule hardening (bare displayText no longer trusted without pairing evidence, empty displayText authoritative, strict non-nesting tag matcher), and the consumer rollout (insight DataProcessor, session-reference-service, goal-evidence, conversation-branches, webui, vscode, desktop, Core Mandates tag instruction) are genuinely new and absent from main. Context still reaches the model (tagged part appended to request, recorded verbatim); resume/replay lose/duplicate nothing; tags can't be forged (angle brackets escaped). CI green on current head (ubuntu Test, Desktop Shell, web-shell E2E all success). Non-blocking P2/P3: background-agent-resume initialPrompt still reads raw model-bound parts (pre-existing gap, route through the projection in a follow-up); displayText semantics shift (submittedPrompt preferred, empty=authoritative) is a user-visible change worth a release note; tag matcher now duplicated 3x across core/desktop/webui (drift risk — consider a shared Node-free subpath); web-shell transcriptToMessages.ts still has no projection handling (file a follow-up).

@yiliang114

Copy link
Copy Markdown
Collaborator

Relationship to #7956 (for future readers): this is NOT a duplicate — it is the documented follow-up to the already-merged #7956, which introduced the qwen:user-prompt-submit-context tag mechanism, a single-field {displayText?} payload, and the clean projection only for CLI resume + ACP replay. #7956's own design doc deferred the remaining consumers to follow-ups.

What this PR adds on top of #7956 (all verified absent from main):

  1. The hookContext field on the user-record payload (main only has displayText), upgrading {displayText?} -> {displayText, hookContext}.
  2. Trust-rule hardening: bare displayText is no longer trusted without pairing evidence, empty displayText is authoritative, and the tag matcher is strict (newline-delimited, no nesting).
  3. The clean-projection rollout to the consumers feat(core): tag UserPromptSubmit hook context and record display provenance #7956 left on the tagged shape: insight analysis (DataProcessor), session-reference-service, goal-evidence, conversation-branches, WebUI (JSONLAdapter/ChatViewer), VS Code (qwenAgentManager/qwenSessionReader), and Desktop (qwen-agent).

So it does not re-solve the original issue — #7956 already solved the core separation; this completes the consumer rollout and hardens the schema/semantics. ~1200 of the ~2000 added lines are tests covering that rollout.

@wenshao
wenshao added this pull request to the merge queue Aug 8, 2026
Merged via the queue into QwenLM:main with commit b34a08d Aug 8, 2026
169 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.8.

@QwenLM QwenLM deleted a comment Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UserPromptSubmit additionalContext pollutes user-message JSONL and resume display

6 participants