Skip to content

feat(core): tag UserPromptSubmit hook context and record display provenance - #7956

Merged
doudouOUC merged 12 commits into
QwenLM:mainfrom
zjgzx1988:feat/user-prompt-submit-context-provenance
Jul 30, 2026
Merged

feat(core): tag UserPromptSubmit hook context and record display provenance#7956
doudouOUC merged 12 commits into
QwenLM:mainfrom
zjgzx1988:feat/user-prompt-submit-context-provenance

Conversation

@zjgzx1988

Copy link
Copy Markdown
Collaborator

What this PR does

When a UserPromptSubmit hook returns additionalContext, the injected text is now appended to the model-bound request as its own part wrapped in a reserved <qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context> tag, instead of as bare text. Hook output already has angle brackets escaped, so the tag cannot be forged from inside the injected content, and user-authored text is never rewritten.

The user record in the session transcript keeps the exact model-bound parts (so resume replays what the model actually saw), and additionally stores the pre-injection user prompt as systemPayload.displayText plus the injected string as systemPayload.hookContext — the same displayText separation already used for mid-turn and notification records. Telemetry prompt attributes and managed auto-memory recall now use the pre-injection prompt text instead of the augmented request.

On the read side, the resume projection restores user turns through a three-shape fallback: new records prefer displayText; records that carry the tag but no payload drop a trailing part only when that part is, in its entirety, a tagged block (and never when it is the sole part, which can only be user-authored); legacy records with bare injected text keep the current concatenation behavior. The ACP session path, which already records the pre-injection prompt, gets the same tag wrapping on its model-bound injection for consistency.

Why it's needed

Injected hook context was persisted indistinguishably from what the user typed. Resumed sessions displayed hook-injected context as if the user had written it, offline transcript analysis could not separate the two, and downstream consumers had to strip vendor-specific markers with fragile regexes. Telemetry and memory-recall queries were also polluted by the injected text. The live TUI was unaffected (it renders the pre-hook input), which made the transcript pollution easy to miss.

Reviewer Test Plan

How to verify

  1. Configure a UserPromptSubmit hook that returns additionalContext, e.g. a command hook echoing {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"extra context"}}.
  2. Submit a prompt and inspect the session JSONL under the project chats directory: the user record's last message.parts entry should be the injected context wrapped in <qwen:user-prompt-submit-context> tags, and systemPayload should carry displayText (the original prompt) and hookContext.
  3. Resume the session (--resume / session picker): the user turn should display only the original prompt text, not the injected context.
  4. Resume a session recorded before this change (bare injected text): display is unchanged (legacy fallback).
  5. A prompt whose entire text is a literal <qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context> block is still displayed verbatim on resume (sole-part guard).

Unit coverage: tag helper tests, client tests for tagged injection + recording payload + pre-injection recall query, recording-service payload tests, and resume-projection fixtures for all three record shapes.

Evidence (Before & After)

Headless run with a UserPromptSubmit hook injecting E2E injected context marker-7940, then inspecting the recorded user record.

Before:

{"type":"user","systemPayload":null,"parts":["hello marker test","E2E injected context marker-7940"]}

After:

{"type":"user","systemPayload":{"displayText":"hello marker test AFTER","hookContext":"E2E injected context marker-7940"},"parts":["hello marker test AFTER","<qwen:user-prompt-submit-context>\nE2E injected context marker-7940\n</qwen:user-prompt-submit-context>"]}

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests via vitest; headless JSONL evidence via the bundled CLI (npm run bundle, node dist/cli.js -p ...).

Risk & Scope

  • Main risk or tradeoff: models now see the injected context wrapped in a tag; hook-context content is unchanged inside the wrapper. Transcript consumers that regex-matched bare injected text keep working for legacy records; new records are strictly easier to identify.
  • Not validated / out of scope: subagent context injection (SubagentStart via contextState) needs its own investigation; desktop/web transcript viewers can adopt displayText in follow-ups.
  • Breaking changes / migration notes: none. recordUserMessage gains an optional trailing parameter; the payload is only written when a hook actually injected context, and old records remain readable through the fallback.

Linked Issues

Closes #7940

中文说明

本 PR 做了什么

当 UserPromptSubmit hook 返回 additionalContext 时,注入文本现在会作为独立 part 追加到发给模型的请求中,并用保留标签 <qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context> 包裹,而不再是裸文本。hook 输出中的尖括号本就会被转义,因此注入内容无法伪造闭合标签;用户自己输入的文本不会被改写。

会话记录中的 user 记录仍保存与模型所见完全一致的 parts(resume 必须回放模型实际看到的内容),同时把注入前的用户原文存入 systemPayload.displayText、注入串存入 systemPayload.hookContext —— 与 mid-turn / notification 记录已有的 displayText 分离模式同构。遥测的 prompt 属性和自动记忆召回查询改用注入前的原文。

读路径上,resume 投影按三种记录形态回退:新记录优先用 displayText;只有标签没有 payload 的记录,仅当末尾 part 整体是一个标签块时才剥离(且该 part 不是唯一 part —— 唯一 part 只可能是用户输入,保留原样);旧的裸注入记录保持现有拼接行为不变。ACP 会话路径本就单独记录注入前原文,这里为一致性给它的模型侧注入也加上相同的标签包裹。

为什么需要

hook 注入的上下文此前与用户输入无法区分地落盘:resume 时把注入内容当作用户输入显示,离线分析无法分离两者,下游只能用脆弱的正则剥离私有标记;遥测与记忆召回查询也被注入文本污染。交互式 TUI 因为用 pre-hook 输入渲染而不受影响,这正是该问题容易被忽视的原因。

审阅者验证方案

  1. 配置一个返回 additionalContext 的 UserPromptSubmit hook(例如 echo 上述 JSON 的 command hook)。
  2. 提交一条 prompt 后检查项目 chats 目录下的会话 JSONL:user 记录 message.parts 的最后一个条目应是被标签包裹的注入内容,systemPayload 应包含 displayText(原文)与 hookContext
  3. Resume 该会话:用户轮次应只显示原文,不显示注入内容。
  4. Resume 一个本改动之前录制的会话(裸注入):显示行为不变(legacy 回退)。
  5. 用户整条消息恰好是一个字面 <qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context> 块时,resume 仍原样显示(唯一 part 守卫)。

单测覆盖:标签 helper、client 的打标注入 + 录制 payload + 注入前召回查询、录制服务 payload、以及三种记录形态的 resume 投影 fixture。

风险与范围

  • 主要风险/权衡:模型看到的注入上下文外层多了标签,内容本身不变;依赖正则匹配裸注入文本的消费者对旧记录不受影响,新记录反而更易识别。
  • 未验证/超出范围:子代理上下文注入(SubagentStartcontextState)需要单独调查;desktop / web 端的记录查看器可在后续跟进采用 displayText
  • 破坏性变更/迁移说明:无。recordUserMessage 新增可选尾参;仅在 hook 实际注入时才写入 payload,旧记录通过回退逻辑保持可读。

关联 Issue

Closes #7940

@yiliang114

Copy link
Copy Markdown
Collaborator

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

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 28, 2026
@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 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with solid evidence. Issue #7940 documents the transcript pollution clearly — hook-injected additionalContext lands indistinguishably in user-message JSONL, breaking resume display and offline analysis. The before/after JSON in the PR description confirms the write-path behavior. The issue is labeled type/bug, priority/P2, and welcome-pr.

Direction: aligned. Session transcript integrity is core to qwen-code's reliability, and the approach is isomorphic to two patterns already in-tree — SessionStart context tagging and systemPayload.displayText separation for mid-turn/notification records. CHANGELOG (Claude Code) shows active investment in hook context handling — the area is relevant, though no direct upstream reference to provenance tagging.

Size: core paths touched (packages/core/src/**, packages/acp-bridge/src/**, packages/cli/src/**). Production logic: ~285 lines (core 146, CLI 44, acp-bridge 95). Test: ~598 lines. Docs: ~88 lines. Config/build: ~12 lines. Well under the 500-line awareness threshold.

Approach: the scope feels right. Write path (tag + record payload), read path (three-shape resume fallback), and the ACP-bridge transcript-replay projection are all needed to close the issue end-to-end. The new commits since the last review add the acp-bridge/transcript-replay path — this was a gap in the original diff, since ACP/export/daemon consumers go through projectUserRecord. The narrow subpath export (@qwen-code/qwen-code-core/userPromptSubmitContext) is architecturally sound: importing the core barrel would pull the whole Node-bound graph into the browser transcript bundle. The ACP Session test and the vitest alias changes are mechanical. I don't see a materially simpler path.

Risk: packages/cli/src/acp-integration/session/Session.ts matches the acp-integration high-risk path pattern. The change is minimal (wrapping the existing bare injection in the same tag helper), but I'm flagging it for focused review attention. The author lacks write access, so sandboxed verification lanes (/verify, /tmux) require a maintainer trigger.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,证据充分。Issue #7940 清楚记录了会话记录污染——hook 注入的 additionalContext 与用户输入无法区分地落入 JSONL,破坏了 resume 显示和离线分析。Issue 已标记 type/bugpriority/P2welcome-pr

方向:对齐。会话记录完整性是 qwen-code 可靠性的核心,方案与代码库中已有的两个模式同构。

规模:触及核心路径。生产逻辑约 285 行(core 146,CLI 44,acp-bridge 95)。测试约 598 行。文档约 88 行。远低于 500 行关注阈值。

方案:范围合理。新提交补充了 acp-bridge/transcript-replay 路径——这是原始 diff 中的缺口。窄子路径导出架构上合理:避免将 Node 依赖拉入浏览器 bundle。没有看到更简路径。

风险:Session.ts 匹配 acp-integration 高风险路径模式。改动极小,但标记以供重点审查。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 60da15458058dc6786965935c087842e8ac98814 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

The implementation is clean and follows existing patterns closely. My independent proposal for this problem was the same shape — tag the injection, record a display projection, fix the downstream consumers — so the approach feels well-calibrated rather than over-engineered.

Write path (client.ts): the preInjectionPromptText variable is set only when a hook actually injects context, and the ?? fallback to partToString(request) keeps the no-injection path unchanged. Telemetry attributes and memory recall both switch to the pre-injection text via the same variable — no scattered conditionals. The recording call passes the payload as an optional trailing argument, so all existing recordUserMessage callers are unaffected. The conditional split (if recordingService && preInjectionPromptText !== undefined / else if recordingService) avoids passing undefined as a third argument, which would break existing two-arg spies — a thoughtful detail.

Tag helper (user-prompt-submit-context.ts): minimal and correct. isUserPromptSubmitContextPartText does a whole-part trimmed match with a length guard. stripTrailingUserPromptSubmitContextPart is generic over T and returns the same array reference when nothing is stripped — good for avoiding unnecessary re-renders/reallocations. The security argument holds: getAdditionalContext() escapes </> to &lt;/&gt; (verified in hooks/types.ts:443), so hook output cannot forge the closing tag.

Resume projection (resumeHistoryUtils.ts): the three-shape fallback is well-structured. The sole-part guard (parts.length > 1 in stripTrailingUserPromptSubmitContextPart) correctly preserves user-authored text that happens to match the tag shape. The @-command branch now falls through to extractUserRecordDisplayText when userText is absent — tested with both cases.

ACP-bridge transcript-replay (transcript-replay.ts, new in this revision): this was the main gap in the earlier diff. The projectUserRecord method now handles plain user records (!record.subtype) through the same three-shape fallback, but adapted for the multimodal emission path — withUserPromptDisplayText replaces all text parts with a single displayText part at the first text position while preserving image parts, and the !replaced fallback appends displayText when the record has only image parts. Both go through projectMessageParts so inlineData survives. The narrow subpath import (@qwen-code/qwen-code-core/userPromptSubmitContext) avoids pulling the Node-bound core barrel into the browser transcript bundle — the comment explains why, and the package.json export follows the existing ./goalWire pattern.

ACP Session (Session.ts): two-line change wrapping the existing bare injection in the same tag helper. Consistent with the interactive path. Test verifies the tag is present and the user prompt is intact.

Recording service (chatRecordingService.ts): UserPromptRecordPayload follows the existing NotificationRecordPayload pattern exactly. The spread into the record is conditional on the payload being provided.

No critical blockers found. No AGENTS.md violations.

Files changed (18 of 18 shown)
File What changed
docs/design/2026-07-28-user-prompt-submit-context-provenance.md Design doc covering the problem, write/read path design, and scope notes
docs/users/features/hooks.md Two-line user-facing doc noting the reserved tag and display behavior
packages/acp-bridge/src/transcript-replay.test.ts Four test cases: displayText preference, image-only fallback, tag-strip, sole-part guard
packages/acp-bridge/src/transcript-replay.ts Plain user record projection with three-shape fallback, multimodal-safe displayText replacement
packages/acp-bridge/vitest.config.ts Alias for the narrow subpath export
packages/cli/src/acp-integration/session/Session.test.ts Test: additionalContext wrapped in reserved tag before sending
packages/cli/src/acp-integration/session/Session.ts ACP path wraps hook context in the same reserved tag (high-risk path, minimal change)
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts Eight test cases covering all three record shapes, @-command interaction, edge cases
packages/cli/src/ui/utils/resumeHistoryUtils.ts New extractUserRecordDisplayText with three-shape fallback, used in two call sites
packages/cli/vitest.config.ts Alias for the narrow subpath export
packages/core/package.json New ./userPromptSubmitContext subpath export (follows ./goalWire pattern)
packages/core/src/core/client.test.ts Tests for tagged injection, recording payload, pre-injection recall query, telemetry
packages/core/src/core/client.ts Tag wrapping, pre-injection prompt capture, telemetry/recall/recording fixes
packages/core/src/hooks/user-prompt-submit-context.test.ts Unit tests for wrap, match, and strip helpers
packages/core/src/hooks/user-prompt-submit-context.ts New module: tag constants, wrap, match, strip helpers
packages/core/src/index.ts Re-exports the new tag helpers
packages/core/src/services/chatRecordingService.test.ts Test for systemPayload storage and absence when no hook injection
packages/core/src/services/chatRecordingService.ts UserPromptRecordPayload interface, optional payload param on recordUserMessage

Testing

CI results for 60da154:

Check Conclusion
Classify PR ✅ success
Real daemon E2E / Java 11 ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
macos-latest / Java 21 ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

All CI checks green on the current head. The ubuntu unit suite (which gates macOS/Windows and integration tests) passed.

Sandboxed verification lanes (/verify, /tmux) are unavailable for this PR — the author is an external contributor without write access. A maintainer can trigger @qwen-code /verify as a sponsored run (carries a pre-execution risk screen and full workspace wipe). The behavioral claim (tagged injection + resume display + transcript-replay projection) is covered by unit tests in the diff across all three packages.

Not verified: live TUI resume behavior (CI path — no local build); ACP session path end-to-end (no sandboxed lane available).

中文说明

代码审查

实现干净,紧密遵循现有模式。我的独立方案与此 PR 同构——标签注入、记录显示投影、修复下游消费者——方案校准得当。

写入路径client.ts):preInjectionPromptText 仅在 hook 实际注入时设置,?? 回退保持无注入路径不变。条件分支避免传递 undefined 作为第三参数——细节周到。

标签 helperuser-prompt-submit-context.ts):极简且正确。安全性论证成立:getAdditionalContext() 转义尖括号(hooks/types.ts:443),hook 输出无法伪造闭合标签。

Resume 投影resumeHistoryUtils.ts):三形态回退结构良好。唯一 part 守卫正确保留用户输入。

ACP-bridge transcript-replay(新提交):填补了原始 diff 的缺口。多模态安全——withUserPromptDisplayText 保留图片 part。窄子路径导出避免将 Node 依赖拉入浏览器 bundle。

ACP SessionSession.ts):两行改动,与交互路径一致。

未发现关键阻塞项。无 AGENTS.md 违规。

测试

CI 在当前 head 上全绿。沙箱验证通道不可用——维护者可触发 @qwen-code /verify

未验证:实时 TUI resume 行为;ACP 会话路径端到端。

Qwen Code · qwen3.8-max-preview

Reviewed at 60da15458058dc6786965935c087842e8ac98814 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, well-scoped fix that follows existing patterns; CI is fully green on the current head, and the new commits close the transcript-replay gap that was the only open question from the previous pass.

This is a textbook fix for a transcript-integrity bug. The problem is real (issue #7940, before/after evidence, maintainer-labeled welcome-pr), the solution borrows directly from two patterns already in-tree (SessionStart tagging, displayText separation), and the implementation is minimal — 285 production lines across 7 source files in three packages, with 598 lines of tests covering every record shape, edge case, and consumer path.

The new commits since the last review are the strongest signal of quality: rather than waiting for a reviewer to flag the missing ACP-bridge transcript-replay path, the author proactively added it — with the same three-shape fallback, multimodal-safe part handling, and a narrow subpath export that respects the browser/Node boundary. The ACP Session test closes the high-risk-path coverage gap.

My independent proposal for this problem was the same shape. I looked for a simpler path and didn't find one — the write-path fix, the read-path fallback, and the transcript-replay projection are all necessary, and the ACP consistency change is two lines.

The security argument is solid: getAdditionalContext() escapes angle brackets (hooks/types.ts:443), so hook output cannot forge the reserved tag. The sole-part guard in the strip helper correctly handles the edge case where a user literally types the tag. All existing recordUserMessage callers are unaffected by the new optional parameter.

One minor note: the PR description mentions systemPayload.hookContext in the before/after JSON, but the shipped code only stores displayText — the hook context is recoverable from the tagged part in message.parts, which is the right call (no redundant storage). The description is slightly ahead of the final implementation; not a blocker.

中文说明

置信度:5/5 —— 干净、范围合理的修复,遵循现有模式;CI 在当前 head 上全绿,新提交填补了上次审查中唯一悬而未决的 transcript-replay 缺口。

这是修复会话记录完整性 bug 的典范。问题真实(issue #7940,before/after 证据,维护者标记 welcome-pr),方案直接借鉴代码库中已有的两个模式,实现极简——3 个包 7 个源文件 285 行生产代码,598 行测试覆盖所有记录形态、边界情况和消费者路径。

新提交是最强的质量信号:作者主动补充了 ACP-bridge transcript-replay 路径,而非等待审查者指出。多模态安全、窄子路径导出、ACP Session 测试——全部到位。

安全性论证可靠。唯一的小注意:PR 描述中的 before/after JSON 提到了 systemPayload.hookContext,但最终代码只存储 displayText——hook 上下文可从 message.parts 中的标签 part 恢复,这是正确的设计(无冗余存储)。

Qwen Code · qwen3.8-max-preview

Reviewed at 60da15458058dc6786965935c087842e8ac98814 · re-run with @qwen-code /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.

LGTM, looks ready to ship — CI landed green after the review. ✅

@zjgzx1988

Copy link
Copy Markdown
Collaborator Author

E2E Test Report

Setup: headless run (node dist/cli.js -p ...) in a scratch project with a command-type UserPromptSubmit hook returning {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"E2E injected context marker-7940"}}. The model endpoint was a dummy address — recording happens before the model call, so the transcript shape is still exercised end-to-end.

Before (bundle built from main): the injected context landed as a bare text part, indistinguishable from user input, with no provenance payload.

{"type":"user","systemPayload":null,"parts":["hello marker test","E2E injected context marker-7940"]}

After (bundle built from this branch): the injected part is wrapped in the reserved tag and the record carries displayText / hookContext.

{"type":"user","systemPayload":{"displayText":"hello marker test AFTER","hookContext":"E2E injected context marker-7940"},"parts":["hello marker test AFTER","<qwen:user-prompt-submit-context>\nE2E injected context marker-7940\n</qwen:user-prompt-submit-context>"]}

Unit coverage (all passing on macOS, Node 22):

  • packages/core tag helper: 8 tests
  • packages/core client.test.ts: 304 tests, including tagged injection + recording payload + pre-injection recall query
  • packages/core chatRecordingService.test.ts: 64 tests, including payload persistence/omission
  • packages/cli resumeHistoryUtils.test.ts: 37 tests, including all three record shapes (displayText / tag-only / legacy bare) plus the sole-part and tag-in-prose guards

npm run build, npm run typecheck, prettier, and eslint are clean.

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/30417538235)._

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code attempted to resolve merge conflicts but the run did not complete successfully.

address-summary.md

Merge conflict resolution summary — PR #7956

Root cause

The Goal runtime feature landed on main (multiple commits adding goalPermit, goalRuntime, SendMessageType.Goal, permit validation, and try/catch restructuring of the hook block). This collided with PR #7956, which refactored the UserPromptSubmit hook flow to wrap additional context with wrapUserPromptSubmitContext() and track display provenance via injectedHookContext/preInjectionPromptText. Both touched sendMessageStream() in client.ts.

Textual or semantic

Semantic. Both sides modified the same sendMessageStream method, with overlapping concerns around hook output handling and message recording.

The recordUserMessage call needed parameters from both sides:

this.config.getChatRecordingService()?.recordUserMessage(
  request,
  goalPermit,                    // from main (2nd param)
  injectedHookContext !== undefined
    ? { displayText: preInjectionPromptText, hookContext: injectedHookContext }
    : undefined,                 // from PR (3rd param)
);

What is load-bearing

  1. hookOutput scoping: The PR's wrapUserPromptSubmitContext() wrapping and provenance tracking must live inside the hook execution if block where hookOutput and promptText are declared. Main's try/catch restructuring preserved this scope, so the additional-context injection was placed after the blocking-decision return but before the if block closes.

  2. No duplicate context injection: Main independently added a simple (unwrapped) additional-context injection inside the hook block. This was removed to avoid double-injecting — the PR's wrapped version supersedes it.

  3. Goal permit validation ordering: Main's goalRequiresExactPermit check runs in the second try block, after goal runtime loading. It must come after goalOrigin assignment but before the goalPermit options rebinding.

What I could not verify

  • The recordUserMessage signature now accepts (message, goalContext?, payload?) — main added the second param, the PR the third. The merged call passes both. The service implementation on main already has this signature. Not verified by build/test.
  • The hookOutput.getEffectiveReason() call (without optional chaining) inside the blocking check is a pre-existing pattern from both sides — not changed by this merge.
中文说明

合并冲突解决摘要 — PR #7956

根本原因

Goal 运行时功能合入 main(多个提交添加了 goalPermitgoalRuntimeSendMessageType.Goal、permit 验证以及 hook 块的 try/catch 重构),与 PR #7956 冲突。PR #7956 重构了 UserPromptSubmit hook 流程,用 wrapUserPromptSubmitContext() 包装额外上下文,并通过 injectedHookContext/preInjectionPromptText 追踪显示来源。两者都修改了 client.ts 中的 sendMessageStream() 方法。

文本冲突还是语义冲突

语义冲突。 双方都修改了同一个方法,且在 hook 输出处理和消息记录方面有重叠的关注点。recordUserMessage 调用需要合并双方的参数:main 的 goalPermit(第二参数)和 PR 的 hook 上下文元数据(第三参数)。

关键约束

  1. hookOutput 作用域:PR 的包装和来源追踪代码必须在 hook 执行 if 块内部,因为 hookOutputpromptText 在此处声明。
  2. 避免重复注入:main 独立添加了简单版本的额外上下文注入,已移除以避免双重注入——PR 的包装版本取而代之。
  3. Goal permit 验证顺序:main 的 goalRequiresExactPermit 检查在第二个 try 块中运行,必须在 goal 运行时加载之后、goalPermit 选项重绑定之前。

无法验证的内容

  • recordUserMessage 签名接受 (message, goalContext?, payload?),main 添加了第二参数,PR 添加了第三参数。未通过构建/测试验证。

Check the workflow run for full logs.

zjgzx1988 and others added 2 commits July 29, 2026 11:00
…enance

UserPromptSubmit additionalContext was appended to the request as a bare
text part and persisted verbatim, so hook-injected text was
indistinguishable from user-authored text in the transcript, polluted
resumed sessions, telemetry, and auto-memory recall queries.

- Wrap injected context in a reserved
  <qwen:user-prompt-submit-context> tag (hook output already escapes
  angle brackets, so the tag cannot be forged from inside).
- Record the pre-injection user prompt as systemPayload.displayText plus
  the injected string as hookContext on the user record; the model-bound
  message stays verbatim for faithful resume replay.
- Use the pre-injection prompt text for telemetry prompt attributes and
  managed auto-memory recall.
- Resume projection prefers displayText, strips a trailing whole-part
  tagged block when no payload exists, and leaves legacy bare-injected
  records unchanged.
- Apply the same tag wrapping on the ACP session injection path, which
  already records the pre-injection prompt.

Closes QwenLM#7940

Co-authored-by: Cursor <cursoragent@cursor.com>
Document the conflict-resolution constraint that promptText must be
declared before the injection assignment, and the sole-part read-path
guard that keeps a user-authored whole-tag message intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zjgzx1988
zjgzx1988 force-pushed the feat/user-prompt-submit-context-provenance branch from 0d3b1e7 to 2e277fa Compare July 29, 2026 03:02
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@zjgzx1988

Copy link
Copy Markdown
Collaborator Author

Rebase follow-up

Addressed the blocking review item:

  1. Rebased onto current main (includes Goal v3 #7895 and later). The auto-merge had briefly misplaced the tagged-injection block into the Goal permit section; resolved by:
    • keeping the Goal admit/finish/try/catch structure from main
    • placing tagged injection inside the UserPromptSubmit if, after const promptText = partToString(request) (TDZ-safe — preInjectionPromptText = promptText cannot run before promptText is initialized)
    • passing both goalPermit and the optional UserPromptRecordPayload into recordUserMessage
  2. Docs: design doc now explicitly records the promptText-before-injection ordering constraint and the sole-part resume guard.

Verification after rebase: user-prompt-submit-context / client / chatRecordingService / resumeHistoryUtils unit suites all green (413 tests).

DingTalk doc wasn’t readable from this environment (login wall), so if there are additional minor doc nits beyond the TDZ/sole-part notes above, please paste them (or drop them on the PR) and I’ll land a follow-up.

Confirm the at_command branch still prefers payload.userText when a
paired user record carries a trailing tagged hook-context part, and
falls back to the tag-stripping projection only when userText is absent.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zjgzx1988

Copy link
Copy Markdown
Collaborator Author

Minor follow-up

Read the DingTalk CR doc; the only non-blocking item was:

rebase 后核对 at_command 分支 payload.userText || extractUserRecordDisplayText(record) 仍正确显示 at-command 文本

Addressed in bb1cf4352:

  • Prefer AtCommandRecordPayload.userText is unchanged — when present, the helper is never consulted, so a trailing <qwen:user-prompt-submit-context> part cannot override the @-command display text.
  • Added regression tests: (1) userText present + trailing tagged part → shows userText; (2) userText absent + trailing tagged part → falls back to tag-stripped projection.
  • resumeHistoryUtils suite: 39/39 green.

No further open items from that CR note.

@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

Comment on lines +49 to +51
const payload = record.systemPayload as { displayText?: string } | undefined;
if (payload?.displayText) {
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] This anonymous structural cast as { displayText?: string } | undefined can use the named UserPromptRecordPayload type this PR defines (re-exported from @qwen-code/qwen-code-core). This file already imports the sibling SlashCommandRecordPayload / AtCommandRecordPayload types for the same systemPayload narrowing (lines 15-16), so the named type matches local convention. — Concrete cost: if UserPromptRecordPayload.displayText is renamed or retyped later, this cast still compiles against the stale shape; payload?.displayText becomes undefined at runtime, the function falls through to the tag-stripping path, and resumed sessions display hook-injected text as user-authored — the exact regression this PR fixes. The named type surfaces the mismatch at compile time. The fix spans the import and the cast:

// add to the existing import from '@qwen-code/qwen-code-core' (lines 11-18)
  SlashCommandRecordPayload,
  AtCommandRecordPayload,
  UserPromptRecordPayload,

// and narrow with the named type
const payload = record.systemPayload as UserPromptRecordPayload | undefined;
中文说明

[建议] 这里的匿名结构断言 as { displayText?: string } | undefined 可以改用本 PR 定义的具名类型 UserPromptRecordPayload(已从 @qwen-code/qwen-code-core 再导出)。本文件已经为相同的 systemPayload 窄化导入了同类的 SlashCommandRecordPayload / AtCommandRecordPayload 类型(第 15-16 行),使用具名类型符合本文件既有约定。— 具体代价:若未来重构将 UserPromptRecordPayload.displayText 改名或改类型,此断言仍会针对过时的形状通过编译;运行时 payload?.displayText 变为 undefined,函数回退到标签剥离分支,resume 时会把 hook 注入的文本显示为用户输入——正是本 PR 要修复的回归。具名类型能在编译期暴露这种不一致。修复涉及 import 与断言两处:

// 加入到现有从 '@qwen-code/qwen-code-core' 的 import(第 11-18 行)
  SlashCommandRecordPayload,
  AtCommandRecordPayload,
  UserPromptRecordPayload,

// 并用具名类型窄化
const payload = record.systemPayload as UserPromptRecordPayload | undefined;

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

Comment on lines +2856 to +2859
parts = [
...parts,
{ text: wrapUserPromptSubmitContext(additionalContext) },
];

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 ACP-path additionalContext tag-wrapping has no test coverage. The existing UserPromptSubmit hook tests in Session.test.ts mock output: {} (no additionalContext) and never exercise this branch. — Concrete cost: if this call is accidentally reverted to the pre-PR bare { text: additionalContext }, no test fails; ACP sessions would inject bare hook context into the model-bound record and resume would display it as user-authored — the regression this PR fixes for the interactive path. Consider adding a test in the describe('UserPromptSubmit hook') block that mocks hookSpecificOutput.additionalContext and asserts the last sent part satisfies isUserPromptSubmitContextPartText.

中文说明

[建议] ACP 路径的这处 additionalContext 标签包裹没有测试覆盖。Session.test.ts 中现有的 UserPromptSubmit hook 测试 mock 的是 output: {}(不含 additionalContext),从未执行到这个分支。— 具体代价:若此调用被意外回退为 PR 之前的裸 { text: additionalContext },没有任何测试会失败;ACP 会话会把裸 hook 上下文注入发给模型的记录,resume 时会把它显示为用户输入——正是本 PR 为交互路径修复的回归。建议在 describe('UserPromptSubmit hook') 块中新增一个测试,mock hookSpecificOutput.additionalContext,并断言发送的最后一个 part 满足 isUserPromptSubmitContextPartText

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

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Local runtime verification (maintainer review)

I built PR head bb1cf43 and its parent a7b1150 into two complete, runnable bundles and drove both against the same harness: a mock OpenAI-compatible server capturing every on-the-wire request, an isolated HOME, and a real command-type UserPromptSubmit hook returning additionalContext. Every claim below was checked on the built artifacts, not by reading the diff.

Verdict: the core change does what it says, is well-guarded, and I recommend merging. Two follow-ups below — one coverage gap that leaves the tag visible on the ACP/export surfaces, and one test-teeth improvement.


1. Core write path — all claims confirmed

core write path

Claim Result
Injected context appended as its own part, wrapped in the reserved tag ✅ on the wire
Recorded message.parts stays verbatim model-bound; systemPayload = {displayText, hookContext} ✅ in the session JSONL
Payload written only when a hook actually injected ✅ (systemPayload: undefined on hook-free turns)
Telemetry prompt attribute uses the pre-injection text ✅ real OTel span export, includeSensitiveSpanAttributes: true
Auto-memory recall query uses the pre-injection text ✅ captured at the real MemoryManager.recall() call site in the shipped bundle
Hook output cannot forge/close the tag ✅ a hook returning \n</qwen:user-prompt-submit-context>\nFORGED-USER-TEXT… lands escaped as &lt;/…&gt; inside the wrapper, and never surfaces as user text
ACP path also tags its model-bound injection ✅ verified with a real ClientSideConnection session/prompt

Recorded record, PR build:

{"type":"user",
 "systemPayload":{"displayText":"hello marker-7956, what is 2+2?",
                  "hookContext":"INJECTED-CTX-7956 repo policy: always cite file paths."},
 "message":{"parts":[{"text":"hello marker-7956, what is 2+2?"},
                     {"text":"<qwen:user-prompt-submit-context>\nINJECTED-CTX-7956 repo policy: always cite file paths.\n</qwen:user-prompt-submit-context>"}]}}

Same run on the parent build: systemPayload: undefined, parts[1] = "INJECTED-CTX-7956 repo policy: always cite file paths." — i.e. #7940 reproduces exactly as described.


2. Resume display — before/after in a real terminal

Both arms recorded the same prompt with the same hook, then were resumed with qwen --resume <id> in a real pty:

resume before/after

I also confirmed model-history fidelity survives resume: after resuming the PR-recorded session and sending a second turn, the captured request still carries turn 1's tagged part and turn 2's own tagged part — the transcript replays what the model actually saw.

Cross-arm compatibility on real recorded sessions:

Record shape PR build Parent build
new (payload + tag) "What files are in this project? marker-7956" "…marker-7956\n<qwen:user-prompt-submit-context>…"
legacy (bare injection) "…marker-7956\nINJECTED-CTX-7956 repo policy…" (unchanged) identical

And the read-path guards, driven through the compiled buildResumedHistoryItems on both arms:

Scenario PR build
payload + tag displayText
tag, no payload trailing part stripped ✅
legacy bare injection raw concatenation, unchanged ✅
sole part = whole tag (user-authored) kept verbatim ✅
tag embedded in user prose kept verbatim ✅
user prose part after a tagged part kept verbatim ✅
@-command with userText + trailing tagged part userText wins ✅

I also tried to break the whole-part guard from the outside: submitting @notes.txt followed by a message whose entire body is a literal <qwen:user-prompt-submit-context>…</qwen:user-prompt-submit-context> block, with no hook configured. The record keeps the user's instruction and resume renders it verbatim on both arms — the guard holds.


3. Suites, lint, mutation testing

  • packages/core user-prompt-submit-context.test.ts — 8/8 ✅
  • packages/cli resumeHistoryUtils.test.ts — 39/39 ✅
  • packages/acp-bridge transcript-replay.test.ts — 13/13 ✅ (no regression)
  • packages/cli acp-integration/session/Session.test.ts — 456/456 ✅
  • ESLint on all five changed production files — clean ✅

Mutation testing (revert a behaviour, re-run the PR's own suites):

Mutant Result
wrapUserPromptSubmitContext returns bare context CAUGHT
startsWith/endsWithincludes (unanchored match) CAUGHT
drop the trim() CAUGHT
parts.length > 1> 0 (kill the sole-part guard) CAUGHT
don't strip the trailing tagged part CAUGHT
@-command fallback reverted to raw extractTextFromParts CAUGHT
delete the payload.displayText preference entirely SURVIVED — see finding 2

Note: packages/core/src/core/client.test.ts and chatRecordingService.test.ts cannot collect in my environment (logStartSession mock error and a node:crypto/uuid mock error respectively). Both fail identically at the PR parent, so this is a pre-existing environment artifact and not caused by this PR — but it does mean the three new core tests gave me no local signal, which is why I verified those behaviours at runtime instead.


Finding 1 — the tag is still shown as user text on the ACP / export surfaces (follow-up)

The TUI is fixed, but packages/acp-bridge/src/transcript-replay.ts projectUserRecord only honours systemPayload.displayText for subtype in goal_runtime | notification | cron | mid_turn_user_message. A plain UserPromptSubmit-augmented record has no subtype, so it falls through to projectMessageParts, which emits every text part — including the tagged one — as a user_message_chunk.

residual leak

Reproduced on the PR build with a real ACP client doing session/load on a PR-recorded session (4 user_message_chunk frames, 2 of them the raw tag), and with a real /export md of the same session, which writes the tag under ## User.

Surfaces affected: Zed / VS Code companion / web-shell / daemon TUI (daemon-tui-adapter.ts), /export (md / html / json, all via ExportSessionContext.sendUpdate), and qwen serve session export — i.e. exactly the "offline analysis / downstream consumers" case in the PR's own problem statement. Before this PR they showed bare injected text; now they show XML markup, which is arguably more conspicuous.

Suggested minimal change (either in this PR or a fast follow-up): in projectUserRecord, for a subtype-less user record, prefer systemPayload.displayText when present, and otherwise drop a trailing whole-part tagged block via the same isUserPromptSubmitContextPartText helper. That reuses the mechanism already present in that function.

Related: docs/users/features/hooks.md currently states the tag keeps the context distinguishable "in model history, session transcripts, and resumed sessions" and that "the interactive UI displays that original text". That is accurate for the TUI; I'd soften or scope the transcript-consumer wording until the replay path lands.

Finding 2 — one new test has no teeth (1-fixture fix)

it('prefers recorded displayText over the augmented parts') still passes when the entire displayText branch is deleted, because for its fixture (['my prompt', tagged]) the tag-strip fallback produces the same string. Adding one fixture where the two branches disagree makes the mutant fail:

it('prefers displayText over the tag-strip fallback', () => {
  const items = buildUserItems({
    type: 'user',
    message: {
      parts: [{ text: 'my prompt' }, { text: 'expanded extra' }, { text: tagged }],
    },
    systemPayload: { displayText: 'my prompt', hookContext: 'injected hook context' },
  });
  expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
});

I ran this: 40/40 pass on the PR as-is, and the "delete the displayText branch" mutant goes from SURVIVED to CAUGHT.

Finding 3 — informational: the ACP write path never persists the hook context

Session.ts:2751 calls recordUserMessage(promptText) before the hook fires, with the pre-injection text only. So an ACP-recorded transcript contains no hook context at all, and resuming an ACP session silently drops it from model history — while the interactive path deliberately preserves it ("resume must replay what the model actually saw"). Confirmed at runtime: ACP prompt → wire request carries the tagged part, recorded record has a single part "acp marker-7956 hello" and no payload.

This is pre-existing and not introduced here, so it shouldn't block the merge — but after this PR the two paths have genuinely different transcript semantics, and the design doc's fidelity rationale only holds on one of them. Worth a tracking issue alongside the SubagentStart follow-up already listed as out of scope.


中文版

本地运行时验证(维护者评审)

我把 PR head bb1cf43其父提交 a7b1150 分别构建成两套完整可运行的产物,用同一套 harness 驱动:mock OpenAI 服务端(抓取每一次真实出网请求)、隔离的 HOME、以及一个真实的 command 类型 UserPromptSubmit hook(返回 additionalContext)。下面每条结论都基于构建产物的实际运行,而非阅读 diff。

结论:核心改动确实做到了它声称的事情,边界守卫也扎实,建议合入。 另有两点后续项 —— 一处覆盖缺口(ACP / export 侧仍会把标签当用户文本展示),以及一处单测"咬合力"的改进。

1. 写路径 —— 全部claim得到确认

Claim 结果
注入内容作为独立 part 追加,并被保留标签包裹 ✅ 出网请求上确认
记录的 message.parts 与模型所见完全一致;systemPayload = {displayText, hookContext} ✅ 会话 JSONL 中确认
仅在 hook 真正注入时才写 payload ✅(无 hook 的轮次 systemPayload: undefined
遥测 prompt 属性使用注入前原文 ✅ 真实 OTel span 导出验证
自动记忆召回查询使用注入前原文 ✅ 在打包产物中 MemoryManager.recall() 真实调用点捕获
hook 输出无法伪造/闭合标签 ✅ 返回 \n</qwen:user-prompt-submit-context>\nFORGED-USER-TEXT… 的 hook,其内容被转义为 &lt;/…&gt; 留在包裹内,且从不作为用户文本出现
ACP 路径的模型侧注入同样打标 ✅ 用真实 ClientSideConnectionsession/prompt 验证

同一 prompt 在父提交上运行:systemPayload: undefinedparts[1] = "INJECTED-CTX-7956 repo policy: always cite file paths." —— #7940 完全复现。

2. Resume 展示 —— 真实终端下的 before/after

两个 arm 用相同 prompt、相同 hook 录制会话,再用 qwen --resume <id> 在真实 pty 中恢复(见上方第一张截图)。

我还确认了 resume 后模型历史的保真性:恢复 PR 录制的会话并发第二轮后,抓到的请求同时携带第一轮的标签 part 和第二轮自己的标签 part —— 会话记录确实回放了模型真正看到的内容。

跨 arm 兼容性(真实录制会话):

记录形态 PR 构建 父提交构建
新记录(payload + 标签) "What files are in this project? marker-7956" "…marker-7956\n<qwen:user-prompt-submit-context>…"
旧记录(裸注入) "…marker-7956\nINJECTED-CTX-7956 repo policy…"(行为不变) 完全一致

读路径守卫(在两个 arm 的编译产物上驱动 buildResumedHistoryItems):payload 优先 ✅、无 payload 时剥离末尾整块标签 ✅、旧裸注入拼接不变 ✅、唯一 part 是整块标签时原样保留 ✅、标签混在用户散文中原样保留 ✅、标签 part 之后还有用户 part 时原样保留 ✅、@ 命令 userText 优先 ✅。

我还从外部尝试攻破整块守卫:在没有配置 hook 的情况下提交 @notes.txt + 一条整体就是字面 <qwen:user-prompt-submit-context>…</qwen:user-prompt-submit-context> 的消息。记录保留了用户指令,两个 arm 的 resume 都原样渲染 —— 守卫成立。

3. 测试、lint 与变异测试

  • user-prompt-submit-context.test.ts 8/8 ✅;resumeHistoryUtils.test.ts 39/39 ✅;transcript-replay.test.ts 13/13 ✅;Session.test.ts 456/456 ✅;五个改动的生产文件 ESLint 全部通过 ✅

变异测试(反向改回某个行为,再跑 PR 自己的用例):7 个变异体中 6 个被捕获;唯独"整体删除 payload.displayText 优先分支"存活 —— 见发现 2。

说明:client.test.tschatRecordingService.test.ts 在我的环境下无法 collect(分别是 logStartSession mock 报错和 node:crypto/uuid mock 报错)。两者在 PR 父提交上同样失败,属于既有环境问题、非本 PR 引入;但这也意味着新增的三个 core 用例在本地没有信号,所以我改用运行时手段验证了对应行为。

发现 1 —— ACP / export 侧仍把标签当用户文本展示(后续项)

TUI 已修好,但 packages/acp-bridge/src/transcript-replay.tsprojectUserRecord 只对 subtypegoal_runtime | notification | cron | mid_turn_user_message 的记录读取 systemPayload.displayText。而被 UserPromptSubmit 增广的普通 user 记录没有 subtype,于是落到 projectMessageParts,把包括标签 part 在内的每个文本 part 都作为 user_message_chunk 发出。

已在 PR 构建上复现:用真实 ACP 客户端对 PR 录制的会话做 session/load(4 个 user_message_chunk,其中 2 个是裸标签);以及对同一会话执行真实的 /export md,标签被写进 ## User 段落。

受影响面:Zed / VS Code companion / web-shell / daemon TUI(daemon-tui-adapter.ts)、/export(md / html / json,均经由 ExportSessionContext.sendUpdate)、以及 qwen serve 的会话导出 —— 正是 PR 问题陈述里点名的"离线分析 / 下游消费者"场景。本 PR 之前它们显示的是裸注入文本,现在显示的是 XML 标记,观感上反而更显眼。

建议的最小改动(本 PR 内或紧随其后的跟进均可):在 projectUserRecord 中,对无 subtype 的 user 记录优先使用 systemPayload.displayText;没有 payload 时用同一个 isUserPromptSubmitContextPartText helper 剥离末尾整块标签。这完全复用该函数里已有的机制。

相关:docs/users/features/hooks.md 目前写的是标签让注入内容"在模型历史、会话记录、恢复的会话中都保持可区分",且"交互式 UI 显示原文"。对 TUI 是准确的;在 replay 路径落地之前,建议把关于记录消费者的措辞收敛或限定范围。

发现 2 —— 有一个新增用例没有咬合力(补一条 fixture 即可)

it('prefers recorded displayText over the augmented parts')整体删除 displayText 分支后依然通过 —— 因为对它的 fixture(['my prompt', tagged])而言,剥标签的回退分支产出完全相同的字符串。补一条让两个分支产出不同的 fixture 即可让变异体失败(见英文版代码片段)。

我实测过:该 fixture 在当前 PR 上 40/40 通过,而"删除 displayText 分支"的变异体从 SURVIVED 变为 CAUGHT。

发现 3 —— 信息性:ACP 写路径从不持久化 hook 上下文

Session.ts:2751 在 hook 触发之前调用 recordUserMessage(promptText),只传注入前原文。因此 ACP 录制的会话记录里根本没有 hook 上下文,恢复 ACP 会话时它会从模型历史中静默丢失 —— 而交互式路径是刻意保留的("resume 必须回放模型实际看到的内容")。运行时已确认:ACP 发一轮 prompt,出网请求带标签 part,而记录只有单个 part "acp marker-7956 hello" 且无 payload。

这是既有问题、非本 PR 引入,不应阻塞合入;但本 PR 之后两条路径的记录语义确实产生了分歧,设计文档里的保真性论证也只在其中一条上成立。建议与已列为 out of scope 的 SubagentStart 跟进项一起开个 issue 跟踪。

Omit the optional UserPromptRecordPayload third arg when no hook
injected, so Goal admission spies expecting two args stay exact and
CI client-goal.test.ts passes.

Project plain UserPromptSubmit-augmented records through
transcript-replay with the same displayText / trailing-tag strip
fallback as the TUI, covering ACP/export surfaces. Strengthen the
displayText preference fixture so it disagrees with the tag-strip
path, and use the named UserPromptRecordPayload type in resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zjgzx1988

Copy link
Copy Markdown
Collaborator Author

Review follow-up (986c1fe7f)

Addressed the maintainer's local-runtime findings and the CI red:

CI — client-goal.test.ts

recordUserMessage was always called with a trailing undefined payload arg when no hook injected. Vitest records that as a third argument, so the Goal admission spy expecting (parts, permit) failed. Now the third arg is omitted unless a hook actually injected. Local: client-goal.test.ts 22/22 + client.test.ts 304/304 green.

Finding 1 — ACP / export surface still showed the tag

packages/acp-bridge/src/transcript-replay.ts projectUserRecord now applies the same three-shape projection to subtype-less user records: prefer systemPayload.displayText, else strip a trailing whole-part <qwen:user-prompt-submit-context> block (with the sole-part guard). Covers ACP session/load, /export, daemon TUI, web-shell consumers that go through this replay path. Added 3 fixtures in transcript-replay.test.ts (16/16).

Finding 2 — displayText preference had no teeth

Added prefers displayText over the tag-strip fallback with a fixture where the branches disagree (['my prompt', 'expanded extra', tagged] + displayText: 'my prompt'). resumeHistoryUtils 40/40.

Docs

Scoped hooks.md wording to TUI + transcript-replay consumers (no longer implies every offline consumer already strips). Design doc notes the ACP/export path is covered.

Also

  • resumeHistoryUtils now narrows via the named UserPromptRecordPayload type (bot Suggestion).

Finding 3 (ACP write path never persists hook context) left as informational / tracking — pre-existing asymmetry, not blocking this PR.

wenshao and others added 3 commits July 29, 2026 16:34
CLI and acp-bridge Vitest configs already map goalWire/transcriptRecords
to TypeScript sources; without the same alias the new package export
fails import analysis and breaks dozens of CLI suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
Preferring UserPromptSubmit displayText previously returned early and
skipped projectMessageParts, dropping multimodal inlineData. Rebuild
parts so displayText replaces text while images keep their order.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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 +607 to +609
if (!replaced) {
nextParts.push({ text: 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 !replaced fallback in withUserPromptDisplayText — an image-only record carrying systemPayload.displayText with no text part to replace — is not exercised by any test. — Concrete cost: the only image fixture (transcript-replay.test.ts:200) also contains text parts, so replaced is always true and this append-at-end branch ships untested; if the placement were wrong (a downstream consumer expecting text before images), the ordering regression would fail no test. Suggested fix: add a transcript-replay.test.ts case whose parts are [{ inlineData: { data: 'abc', mimeType: 'image/png' } }, { text: tagged }] with systemPayload: { displayText: 'my image prompt' }, asserting the projection emits the image part followed by the displayText part.

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

@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. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

Comment on lines +405 to +406
/** Sanitized additional context injected by the hook (without the tag). */
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.

[Suggestion] hookContext is declared in UserPromptRecordPayload, written to every hook-augmented JSONL record, and asserted in tests — but has zero read sites in the entire codebase.

Concrete cost: this field enters the persisted transcript format with no consumer and no documented purpose. A future maintainer modifying the JSONL schema cannot determine whether hookContext is load-bearing (an offline tool reads it), redundant (recoverable from the tagged part in message.parts), or dead. Dropping it later risks a backwards-incompatible format change if an external consumer was added silently. Per AGENTS.md: "For every added field, grep its read sites, including outside the diff."

Either remove hookContext from the payload (the tagged context in message.parts is already machine-parseable via isUserPromptSubmitContextPartText), or add a comment on the interface explaining which downstream reader it serves.

中文说明

hookContextUserPromptRecordPayload 中声明,写入每条 hook 增强的 JSONL 记录,并在测试中断言——但在整个代码库中没有任何读取点

具体成本:该字段进入了持久化的转录格式,但没有消费者,也没有文档说明其用途。未来维护者修改 JSONL 模式时无法判断 hookContext 是否是关键的(外部工具读取它)、冗余的(可从 message.parts 中的标签块恢复),还是死代码。日后删除它可能会因为外部消费者的悄悄添加而导致向后不兼容的格式变更。按照 AGENTS.md:"对于每个新增字段,grep 其读取点,包括 diff 之外的。"

建议从 payload 中移除 hookContextmessage.parts 中的标签上下文已经可以通过 isUserPromptSubmitContextPartText 机器解析),或者在接口上添加注释说明哪个下游读取者使用它。

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge resolution summary — PR #7956

Root cause

Main commit 18cb393e4feat(core): preload deferred tools within a context-window threshold (#7922) — added tokenLimit to the ./tokenLimits.js import in packages/core/src/core/client.ts. This PR had already added a wrapUserPromptSubmitContext import on the adjacent line of the same import block. Two edits to the same import block collided; that was the only conflict (client.ts). client.test.ts, index.ts, chatRecordingService.ts/.test.ts, and hooks.md were also touched by both sides but auto-merged cleanly.

Textual, not semantic

The two sides were only adjacent — they modify different imports used by unrelated functions. Resolution keeps both imports:

import { wrapUserPromptSubmitContext } from '../hooks/user-prompt-submit-context.js';
import { DEFAULT_TOKEN_LIMIT, tokenLimit } from './tokenLimits.js';
  • tokenLimit → consumed by main's new preloadDeferredToolsWithinBudget() (client.ts ~L1109).
  • wrapUserPromptSubmitContext → consumed by this PR's hook-context injection (client.ts ~L2374).

Both symbols are verified exported (tokenLimits.ts:343, user-prompt-submit-context.ts) and used, so neither import is dead.

What is load-bearing

Nothing order-sensitive. The only invariant: the merged tokenLimits.js import must retain both DEFAULT_TOKEN_LIMIT and tokenLimit, and the wrapUserPromptSubmitContext import must stay. A future edit dropping tokenLimit here breaks the deferred-tool preload path silently (it falls back through contextWindowSize first); dropping the other breaks the PR's hook tagging.

What I could not verify

No build/typecheck/tests were run (out of scope for this command). The auto-merged files were resolved by git, not by hand; I confirmed via git diff origin/main HEAD that the PR's characteristic additions (the 48-line user-prompt-submit-context.ts, the index.ts re-exports, chatRecordingService.ts recording hooks, and the client.ts injection) all survived intact. No non-conflicted caller or test is known to depend on changed behaviour from this merge.

中文说明

根因

main 的提交 18cb393e4feat(core): preload deferred tools within a context-window threshold (#7922))在 client.ts./tokenLimits.js 导入中新增了 tokenLimit;本 PR 已在同一导入块的相邻行新增了 wrapUserPromptSubmitContext 导入。两处对同一导入块的修改发生冲突,且这是唯一冲突文件(client.ts)。client.test.tsindex.tschatRecordingService.ts/.test.tshooks.md 双方都改过,但均自动合并成功。

文本冲突,非语义冲突

双方仅相邻,修改的是不同导入,分别被互不相关的函数使用。解决方案是保留两个导入(见上方代码块):tokenLimit 供 main 新增的 preloadDeferredToolsWithinBudget()(约 L1109)使用;wrapUserPromptSubmitContext 供本 PR 的 hook 上下文注入(约 L2374)使用。两个符号均已确认被导出且被使用,无死导入。

关键依赖

无顺序敏感性。唯一不变量:合并后的 tokenLimits.js 导入必须同时保留 DEFAULT_TOKEN_LIMITtokenLimit,并保留 wrapUserPromptSubmitContext 导入。未来若删掉 tokenLimit 会静默破坏延迟工具预加载路径;删掉另一个则会破坏本 PR 的 hook 标记。

未能验证的部分

本命令不运行 build/typecheck/测试。自动合并的文件由 git 处理,非手工解决;我已通过 git diff origin/main HEAD 确认本 PR 的特征性改动(48 行的 user-prompt-submit-context.tsindex.ts 再导出、chatRecordingService.ts 记录钩子、client.ts 注入)均完整保留。没有非冲突的调用方或测试已知依赖本次合并所改变的行为。

@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.

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

UserPromptRecordPayload.hookContext had no read sites; keep displayText
only and recover injected text from the tagged message part. Also cover
the image-only !replaced append path and simplify the recording guard.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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

Share stripTrailingUserPromptSubmitContextPart between TUI resume and
ACP replay, assert ACP Session tags additionalContext, and lock
telemetry to the pre-injection prompt text.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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

Comment on lines +529 to +533
const displayText =
payload && typeof payload['displayText'] === 'string'
? payload['displayText']
: undefined;
yield* this.projectMessageParts(

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 systemPayloaddisplayText extraction here is duplicated verbatim from the first if branch above (the goal_runtime/notification/cron/mid_turn_user_message arm). — Concrete cost: both branches must agree on exactly what counts as a displayText (a string field on an object systemPayload); if that rule is later refined in one branch (e.g. trimming or rejecting empty strings) and the other is missed, plain user records and subtype records will project display text by different rules, producing inconsistent replay output that no single-branch test would catch. The first branch also derives backgroundTask from the same payload, so a shared helper both branches read from captures the whole shared computation:

private getSystemPayload(record: TranscriptRecordInput) {
  return isObjectRecord(record.systemPayload)
    ? record.systemPayload
    : undefined;
}
中文说明

此处 systemPayloaddisplayText 的提取逻辑与上方第一个 if 分支(goal_runtime/notification/cron/mid_turn_user_message 分支)逐字重复。— 具体代价:两个分支必须对“什么算 displayText”(对象 systemPayload 上的字符串字段)保持完全一致;若之后只在一个分支精化该规则(如去空白、拒绝空串)而漏掉另一个,普通 user 记录与 subtype 记录将按不同规则投影显示文本,产生不一致的 replay 输出,只覆盖单分支的测试无法捕获。第一个分支还从同一 payload 派生 backgroundTask,因此抽出两个分支都读取的共享 helper 可覆盖全部共享计算。

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

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@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.

LGTM✅

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🚫 Takeover not engaged: fork takeover requires the PR author to hold write access on this repository (author zjgzx1988 currently: read). A maintainer can adopt the PR instead: snapshot the head into an in-repo branch, open a new PR (commit authorship is preserved), and take that over.

中文说明

🚫 未接管:fork 托管要求 PR 作者在本仓库持有 write 及以上权限(作者 zjgzx1988 当前为:read)。维护者可改用领养:将 head 快照为本仓库分支并另开 PR(commit 署名保留),再对新 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.

LGTM, looks ready to ship. ✅

@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

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 30, 2026
Merged via the queue into QwenLM:main with commit 953c9d8 Jul 30, 2026
37 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UserPromptSubmit additionalContext pollutes user-message JSONL and resume display

6 participants