diff --git a/docs/design/daemon-user-prompt-submit-provenance.md b/docs/design/daemon-user-prompt-submit-provenance.md new file mode 100644 index 00000000000..4bdd2de6e0e --- /dev/null +++ b/docs/design/daemon-user-prompt-submit-provenance.md @@ -0,0 +1,49 @@ +# Daemon UserPromptSubmit provenance + +[English](daemon-user-prompt-submit-provenance.md) | [简体中文](daemon-user-prompt-submit-provenance.zh-CN.md) + +## Problem and evidence + +A normal daemon prompt on main `1919ff97f5` runs the configured UserPromptSubmit hook but omits `submitted_prompt`, so Mem0 Auto Recall returns `{}` without searching. The initial fix inferred provenance from request text on fresh non-channel ACP turns. Review at `2b4f46a` and a failing Session/Hook probe showed that scheduled tasks, sub-session spawns, and Live task dispatches also satisfy that condition. Their machine-composed text must not acquire provenance by default. + +## Scope and ownership + +Preserve the owning session's configuration, message bus, cwd, registration, and environment. Add explicit submission propagation across the existing client, daemon admission, bridge, and ACP child boundaries. Do not change legacy hook invocation, recording, provider configuration, credentials, default extension registration, or managed memory recall. + +REST prompt admission is live-session-owner scoped; it uses the already-resolved owner bridge and runtime. ACP HTTP/WebSocket admission uses its bound session and bridge. No new route or primary-runtime fallback is introduced. + +## Design + +A supported client declares the original submitted text in `_meta["qwen.submittedPrompt"]`. Web Shell captures its composer text before host `prepareSubmit`, slash-command rewriting, and attachment expansion. Ordinary queues store this original declaration independently of the prepared payload. Generic actions, manual scheduled runs, retries, and server-restored queues do not create declarations. Re-submitting restored content from the editor is a new Web Shell submission; this change does not add core TUI-style editor provenance tracking. The existing core TUI producer is unchanged. Both ordinary and queued Web Shell submissions retain their existing admission behavior. Clients must omit the declaration for machine-generated input. This is an explicit per-request contract, not proof of human authorship, authentication, or DLP. + +The REST and ACP transport routes read the declaration and pass it as `submittedPrompt` in the bridge context. REST channel-worker requests, including requests whose worker authorization is no longer valid, do not gain provenance through that route. The bridge strips both public and private submission keys from the request and re-injects only context-declared text as `qwen.daemon.submittedPrompt`; channel and promoted mid-turn dispatches omit it. Internal scheduled, sub-session, Live task, and other automated dispatchers provide no declaration. Realtime voice handoffs also omit the declaration: their request text comes from model-generated tool arguments rather than an independently verified user transcript. + +At ACP process admission, a trusted parent can supply the private key; a direct ACP client can opt in with the public declaration but cannot forge the private key. The public key is consumed at admission and does not pass through to Session. A trusted parent cannot use a public key as a fallback when the private declaration is missing. + +Session emits `submitted_prompt` only for fresh non-channel turns with a nonblank string declaration. Missing, invalid, empty, or whitespace-only values omit the field without falling back to request text or `promptDisplayText`. Preserve the declared whitespace. Display projections retain their existing recording consumers but no longer establish submission provenance. Channel markers cover both automated events and human messages; both remain excluded in this version. + +Keep `isFreshUserTurn` and managed memory recall unchanged. Retry can still invoke legacy hooks but omits submission provenance; continue, restored-question, and runtime-goal turns retain their existing hook exclusions. Tool-result and other internal re-entry loops do not create a declaration. Local-only slash commands that return before model execution remain outside this hook path. + +The initial ACP `prompt` remains the request's pre-expansion text-block join, not the complete expanded model input. TUI-specific Vim, paste, history, undo, and rewind rules do not automatically apply to ACP clients; each client owns the provenance of its declared text. + +## Consumers and compatibility + +The hook pipeline preserves `submitted_prompt` while adding session/cwd metadata. Auto Recall uses it as the search query; every configured hook can receive it. The default Mem0 extension remains MCP-only and Auto Recall still requires explicit v3 configuration and registration. + +Web Shell supplies declarations for user submissions. Other ACP and daemon SDK clients must opt in per eligible request; existing clients without declarations continue invoking legacy hooks but do not trigger provenance-gated retrieval. Do not add a declaration globally to an SDK transport or automated dispatcher. Older daemon versions may ignore this optional metadata, so absence must remain normal for consumers. This changes eligibility from the earlier unmerged implementation, not the released default MCP behavior. + +Newly eligible ACP/daemon payloads include `submitted_prompt`. Administrators whose hooks reject unknown fields, for example through `additionalProperties: false`, must test the deployed hook against the new payload before rollout because rejection can fail open or closed. See [UserPromptSubmit](../users/features/hooks.md#userpromptsubmit) for current semantics and the predecessor design's [Compatibility and migration](submitted-prompt-provenance.md#compatibility-and-migration) for the strict-decoder note only. Its producer table predates headless and ACP support and is not the current eligibility list. + +The Direct Profile's managed launcher remains TTY-only; broader field producers do not expand that deployment contract. A Mem0 v3 profile remains bound to one canonical repository root and scope. Other workspaces skip retrieval; no per-workspace profile routing is added. Sanitization, bounded timeouts, fail-open output, and untrusted context wrapping are unchanged. + +## Validation and acceptance + +- Reproduce the undeclared non-channel failure before editing and preserve the failing assertion. +- Test explicit, missing, invalid, empty, whitespace-only, retry, channel, and model-only cases at Session. Remove the new declaration gate and confirm omission cases fail. +- Test spoofed private keys and public opt-in at admission; verify bridge requests without trusted context cannot acquire provenance. Preserve the raw original text through attachment expansion; model-only content must not replace the declaration. +- Run a real local daemon with an observing hook and controlled model. Explicit ordinary submissions must publish the declared text; marker-less machine submissions must not. Validate the rebuilt bundle, not an older installed binary. +- Run affected package tests, build, bundle, typecheck, formatting, lint, and two full self-audit passes. Record evidence without credentials under `.qwen/e2e-tests/`. + +## Status + +The original implementation at `4fb7d2f0a9` passed 869 Session tests and four local plus four real Holo scenarios. Those results predate the explicit-declaration correction and must not be presented as validation of it. Both synthetic Holo records were removed. Records were seeded/deleted directly through Holo, the model was controlled locally, and workspace B tested exclusion rather than a second profile. The review correction's reproduction and verification are tracked separately in `.qwen/issues/pr-11455-provenance.md`. diff --git a/docs/design/daemon-user-prompt-submit-provenance.zh-CN.md b/docs/design/daemon-user-prompt-submit-provenance.zh-CN.md new file mode 100644 index 00000000000..2fae52899e8 --- /dev/null +++ b/docs/design/daemon-user-prompt-submit-provenance.zh-CN.md @@ -0,0 +1,49 @@ +# Daemon UserPromptSubmit 来源信息 + +[English](daemon-user-prompt-submit-provenance.md) | [简体中文](daemon-user-prompt-submit-provenance.zh-CN.md) + +## 问题与证据 + +main `1919ff97f5` 上的普通 daemon 提问会执行已配置的 UserPromptSubmit hook,但不携带 `submitted_prompt`,因此 Mem0 Auto Recall 返回 `{}` 而不检索。首轮修复从全新非 channel ACP 回合的请求文本推断来源。针对 `2b4f46a` 的评审和失败的 Session/Hook 探针证实,定时任务、子会话派生和 Live task 派发也满足该条件。这些机器组合文本不能默认获得提交来源。 + +## 范围与归属 + +保留所属会话的配置、消息总线、cwd、注册信息与环境。在现有客户端、daemon 准入、bridge 与 ACP 子进程边界之间显式传递提交文本。不改变旧 hook 调用策略、录制、provider 配置、凭证、默认扩展注册或托管记忆召回。 + +REST prompt 准入属于 live-session-owner 范围,使用已解析的 owner bridge 和 runtime。ACP HTTP/WebSocket 准入使用已绑定的会话与 bridge。不增加路由,也不引入 primary-runtime 回退。 + +## 设计 + +受支持的客户端通过 `_meta["qwen.submittedPrompt"]` 声明原始提交文本。Web Shell 在宿主 `prepareSubmit`、斜杠命令改写及附件展开之前捕获输入框文本。普通队列独立保存原始声明与准备后的 payload。通用 action、定时任务手动运行、重试及服务端恢复的队列不会生成声明。用户从输入框重新提交恢复内容属于新的 Web Shell 提交;本次不增加 core TUI 式编辑器来源跟踪。既有 core TUI 生产方保持不变。Web Shell 普通提交与排队提交均保留既有准入行为。客户端必须在机器生成输入上省略声明。这是逐请求的显式契约,不证明由人撰写,不是身份认证或 DLP。 + +REST 与 ACP 传输路由读取声明,通过 bridge context 的 `submittedPrompt` 传递。REST channel-worker 请求不会通过该路由获得来源,包括 worker 授权已失效的请求。bridge 从请求中删除公开与私有提交键,仅将 context 中声明的文本重新注入为 `qwen.daemon.submittedPrompt`;channel 和提升为普通回合的 mid-turn 派发省略该字段。内部定时任务、子会话、Live task 及其他自动派发不提供声明。实时语音委派同样省略声明:其请求文本来自模型生成的工具参数,而非独立核实的用户转写。 + +在 ACP 进程准入处,可信父进程可以提供私有键;直接 ACP 客户端可以通过公开声明逐请求启用,但不能伪造私有键。公开键在准入处消费,不继续传入 Session。可信父进程缺少私有声明时,不能回退到公开键。 + +Session 仅在全新非 channel 回合且声明为非空白字符串时发出 `submitted_prompt`。缺失、非法、空串或纯空白值均省略字段,不回退到请求文本或 `promptDisplayText`。保留声明中的原始空白。显示投影保留已有录制消费方,但不再建立提交来源。channel 标记同时覆盖自动事件与人工消息,本版本继续排除二者。 + +保持 `isFreshUserTurn` 和托管记忆召回不变。retry 仍可调用旧 hook,但省略提交来源;continue、恢复提问和 runtime-goal 保留既有 hook 排除条件。工具结果和其他内部重入循环不会创建声明。在模型执行前返回的纯本地 slash command 仍不经过该 hook 路径。 + +ACP 初始 `prompt` 仍为请求展开前的文本块拼接,并非完整展开后的模型输入。TUI 专属的 Vim、粘贴、历史、撤销与回退规则不会自动适用于 ACP 客户端;各客户端负责所声明文本的来源。 + +## 消费方与兼容性 + +hook 管道补充 session/cwd 元数据时保留 `submitted_prompt`。Auto Recall 将其作为查询文本,每个已配置 hook 都可能收到它。默认 Mem0 扩展仍仅提供 MCP,Auto Recall 仍需显式 v3 配置和注册。 + +Web Shell 为用户提交提供声明。其他 ACP 和 daemon SDK 客户端必须在符合条件的请求上逐次显式启用;没有声明的现有客户端继续调用旧 hook,但不会触发依赖来源的检索。不得在 SDK 传输层或自动派发器中全局添加声明。旧版 daemon 可能忽略这个可选元数据,因此消费方必须继续将缺失视为正常状态。此处调整的是此前未合并实现的资格规则,不改变已发布的默认 MCP 行为。 + +新获得资格的 ACP/daemon payload 包含 `submitted_prompt`。如果管理员的 hook 会拒绝未知字段,例如使用 `additionalProperties: false`,必须在上线前用新 payload 测试已部署 hook,因为拒绝可能导致失败放行或失败关闭。当前语义见 [UserPromptSubmit](../users/features/hooks.md#userpromptsubmit);仅为严格解码器说明引用前序设计的 [Compatibility and migration](submitted-prompt-provenance.md#compatibility-and-migration)。其生产方表早于 headless 与 ACP 支持,不是当前资格清单。 + +Direct Profile 的托管启动器仍仅支持 TTY;字段生产方扩展不会扩大该部署契约。Mem0 v3 profile 仍绑定一个规范化仓库根和 scope。其他 workspace 跳过检索,不增加按 workspace 路由 profile 的功能。清洗、有限超时、失败放行输出和不可信上下文包装保持不变。 + +## 验证与验收 + +- 修改前复现无声明非 channel 回合的问题,保留失败断言。 +- 在 Session 测试显式、缺失、非法、空串、纯空白、retry、channel 与模型专用输入;移除新的声明判定后,确认省略用例失败。 +- 测试准入处对伪造私有键和公开显式启用的处理;确认没有可信 context 的 bridge 请求不能获得来源。附件展开须保留原始文本;模型专用内容不得替换声明。 +- 使用真实本地 daemon、观察 hook 和受控模型。显式普通提交必须发布声明文本;无标记的机器提交不得发布。验证重新构建的 bundle,不使用旧安装版作为修复证据。 +- 执行相关包测试、构建、打包、类型检查、格式检查、lint 和两轮完整自审。在 `.qwen/e2e-tests/` 记录证据,不保存凭证。 + +## 状态 + +原实现 `4fb7d2f0a9` 通过了 869 项 Session 测试,以及本地与真实 Holo 各四项场景。这些结果早于显式声明修正,不能用作该修正的验证证据。两条合成 Holo 记录均已删除。记录直接通过 Holo 创建和删除,模型由本地控制,workspace B 验证的是排除而非第二个 profile。评审修正的复现与验证单独记录在 `.qwen/issues/pr-11455-provenance.md`。 diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 540b0dfce94..1d041fb8a46 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -610,24 +610,28 @@ For `"ask"`, the TUI displays `permissionDecisionReason` as literal text rather #### UserPromptSubmit -**Purpose**: Executed before supported model invocations to validate, block, or enrich the current model-bound prompt. The event currently covers `UserQuery`, `ToolResult`, and `Hook` sends, while `Retry`, `Steer`, `Cron`, `Notification`, and `Teammate` sends are skipped. It can therefore occur on continuation paths, and `prompt` must not be assumed to be raw user input. +**Purpose**: Executed before supported model invocations to validate, block, or enrich their input. On the core/headless path, the event currently covers `UserQuery`, `ToolResult`, and `Hook` sends, while `Retry`, `Steer`, `Cron`, `Notification`, and `Teammate` sends are skipped. It can therefore occur on continuation paths, and `prompt` must not be assumed to be raw user input. The ACP session path has its own invocation policy: retries and newly dispatched background tasks can still invoke legacy hooks; continue, restored-question, and runtime-goal turns do not. **Event-specific fields**: ```json { - "prompt": "current model-bound prompt for this hook invocation", - "submitted_prompt": "optional user text captured at a supported interactive TUI submission boundary" + "prompt": "legacy prompt for this invocation; semantics depend on the execution path", + "submitted_prompt": "optional user text captured at a supported submission boundary" } ``` -`submitted_prompt` is optional. It is present only when Qwen can carry provenance from a supported interactive TUI submission to a fresh `UserQuery`. It is omitted for unsupported producers and machine-driven paths such as same-turn steering, tool-result continuations, retries, cron, notifications, and teammate traffic. ACP, headless, `serve`, SDK, and remote-input paths do not produce it in this version. +`submitted_prompt` is optional. It is present on supported interactive TUI submissions and first-turn headless `UserQuery` sends. On the ACP session path used by ACP clients, `serve`, and daemon hosts, a fresh turn must carry an explicit submission declaration. Missing, non-string, empty, or whitespace-only declarations omit the field; the value is never reconstructed from `prompt` or a display label. Retries, continuations, and channel-classified turns omit it. The channel exclusion includes both automated events and human messages relayed through channel adapters. -Deferred input can retain the field when its provenance remains complete. A combined batch retains provenance only when every constituent item has it; edited, partially known, or otherwise ambiguous input omits the field. Prompt, command, and shell-history navigation or selected search matches, cross-restart stash restores, and conversation rewind restores also omit it because those paths can surface model-bound text without its original provenance. Consumers that require user-submitted text should treat absence as unavailable rather than falling back to `prompt`. +Web Shell provides the original composer text at its submission boundary. Realtime voice handoffs do not declare provenance because their request text comes from model-generated tool arguments. Other ACP/daemon SDK clients can opt in per request with `_meta: { "qwen.submittedPrompt": "original submitted text" }`, captured before resource or model-only expansion. Existing clients without this declaration continue running legacy hooks but do not trigger provenance-gated Auto Recall. Do not add the declaration globally to an SDK transport: scheduled tasks, Live task runs, sub-session spawns, model-authored cross-session messages, and promoted mid-turn messages must not acquire it automatically. The private `qwen.daemon.submittedPrompt` key is reserved for the daemon-to-child hop and is stripped from external callers. These declarations are caller-supplied provenance, not proof of human authorship or authorization. + +On the ACP path, the initial legacy `prompt` is the request's text blocks joined with a space before resource, attachment, slash-command, or model-only expansion. It does not expose the complete expanded model input. `submitted_prompt` can equal that text, but comes only from the explicit declaration and preserves its original whitespace. On the core/headless path, legacy `prompt` represents the current model-bound text for the hook invocation. Neither field is a complete DLP inspection surface. + +The following composer rules apply to the interactive TUI, not to ACP clients. Deferred input can retain the field when its provenance remains complete. A combined batch retains provenance only when every constituent item has it; edited, partially known, or otherwise ambiguous input omits the field. Prompt, command, and shell-history navigation or selected search matches, cross-restart stash restores, and conversation rewind restores also omit it because those paths can surface model-bound text without its original provenance. Consumers that require user-submitted text should treat absence as unavailable rather than falling back to `prompt`. After restored or provenance-unavailable model-bound input is cleared or submitted, the composer also clears its undo and redo history. This prevents undo from restoring expanded text after its marker or sidecar has been consumed. -Large-paste placeholders remain compact in `submitted_prompt`; the expanded pasted content appears only in `prompt`. Consumers should treat the field as a TUI text projection rather than a byte-for-byte record of clipboard input. +Large-paste placeholders remain compact in `submitted_prompt`; the expanded pasted content appears only in `prompt`. On that TUI path, consumers should treat the field as a text projection rather than a byte-for-byte record of clipboard input. ACP clients have no equivalent built-in Vim, paste-placeholder, history, or rewind provenance tracking; they own whether restored or edited text retains a valid submission declaration. Any non-empty input present while Vim mode is enabled omits `submitted_prompt`, including after Vim is disabled, because Vim registers do not carry provenance in this version. This conservative rule also covers drafts entered before enabling Vim. Clearing the composer starts a new eligible input. @@ -665,10 +669,7 @@ This two-field payload is written only for this kind of user-prompt record. `hookContext` intentionally duplicates the tagged part so offline and third-party consumers can identify its provenance without parsing model text. `displayText` is the pre-hook display projection and never includes the hook -context. For a supported interactive TUI submission it is the raw composer -projection carried by `submitted_prompt`; ACP, headless, `serve`, SDK, remote -input, and other paths without that provenance record the expanded pre-hook -prompt instead. +context. On the core/headless path it is the submitted projection when available, otherwise the expanded pre-hook prompt. ACP records the trusted display projection or raw request text before expansion when a projection or attachment references require a payload; otherwise it records the user message without `systemPayload` or `displayText`. Transcript display consumers treat `displayText` as this user-prompt projection when `systemPayload.hookContext` is a string. For compatibility with released @@ -1495,11 +1496,11 @@ A PostToolUse HTTP hook that sends all tool execution records to a remote audit } ``` -### Example 3: Interactive TUI Submitted Prompt Validation Hook +### Example 3: Submitted Prompt Validation Hook -To inspect the current model-bound content instead, read `prompt`. That field can include generated or expanded content, is not the original user input, and does not imply that `UserPromptSubmit` covers every model send. Do not silently fall back from `submitted_prompt` to `prompt` when source provenance is required. +On the core/headless path, `prompt` can include generated or expanded content rather than original user input. On ACP it starts with the pre-expansion request text, so reading it does not inspect attachment bodies or the complete model input. `UserPromptSubmit` does not cover every model send. Do not silently fall back from `submitted_prompt` to `prompt` when source provenance is required. -A UserPromptSubmit hook that validates supported interactive TUI submissions for sensitive information and provides context for long prompts. It skips invocations where source provenance is unavailable. The keyword check is illustrative and is not a complete DLP policy: +A UserPromptSubmit hook that validates supported submitted text and provides context for long prompts. It also runs on headless submissions and explicitly declared ACP/daemon submissions; it is not TUI-only. It skips invocations where source provenance is unavailable. A blocking result stops the affected invocation, including on these non-TUI paths. The keyword check is illustrative and is not a complete DLP policy: **prompt_validator.py** diff --git a/integrations/external-context-mem0/README.md b/integrations/external-context-mem0/README.md index 48f77c3ffad..3811bcebf1e 100644 --- a/integrations/external-context-mem0/README.md +++ b/integrations/external-context-mem0/README.md @@ -260,12 +260,9 @@ could send two searches for one turn. Use the v2 Extension profile for on-demand `context_search`, or the v3 Hook-only profile for Auto Recall, never both in one Qwen process. -The Hook requires a non-empty `submitted_prompt` captured before prompt -expansion. This includes supported interactive TUI submissions and headless -CLI user turns (`qwen -p` and stream-json input, including SDK clients using -that path). The field establishes prompt provenance, not a TUI-only origin. -The Hook never falls back to the expanded `prompt`; events without -`submitted_prompt` do not trigger retrieval. +The Hook requires a non-empty `submitted_prompt` captured before prompt expansion. This includes supported interactive TUI submissions, first-turn headless CLI `UserQuery` sends (`qwen -p` and stream-json input, including SDK clients using that path), and explicitly declared fresh non-channel turns on the ACP session path used by ACP clients, `serve`, and daemon hosts. ACP/daemon clients must provide `_meta: { "qwen.submittedPrompt": "original submitted text" }` per eligible request; Web Shell does so at its submission boundary. Realtime voice handoffs omit declarations because their text comes from model-generated tool arguments. Existing ACP clients without a declaration continue to omit the field. Internal background dispatches and all channel messages, including human messages, are excluded. See [UserPromptSubmit](../../docs/users/features/hooks.md#userpromptsubmit). + +The field establishes prompt provenance, not a TUI-only origin or proof of human authorship. The Hook never falls back to `prompt`; events without `submitted_prompt` do not trigger retrieval. Register this profile only in launchers where automatic retrieval is intended for all eligible inputs. To disable every Hook for an automation run, use diff --git a/integrations/external-context/README.md b/integrations/external-context/README.md index 8ca43b6c570..2590e5e4909 100644 --- a/integrations/external-context/README.md +++ b/integrations/external-context/README.md @@ -237,13 +237,11 @@ write. Cancellation therefore does not prove that no memory was created. ### Auto-recall profile -Auto-recall sends a sanitized best-effort query to the external provider for -each eligible ordinary interactive prompt. It requires a non-empty -`submitted_prompt` captured by the supported interactive TUI before reminders, -file and resource expansion, extension output, and vision expansion. It never -falls back to the legacy model-bound `prompt`. Missing or invalid provenance -fails closed before configuration or credentials are read. Common credential -shapes are removed from the submitted text, but this is not DLP. +This managed Auto Profile supports a fresh interactive TTY launcher, as constrained below. It sends a sanitized best-effort query to the external provider for eligible submissions. The Hook requires a non-empty `submitted_prompt` and never falls back to legacy `prompt`. + +The field itself has a broader producer contract than this managed launcher: supported TUI and first-turn headless submissions, and explicitly declared fresh non-channel turns on the ACP path used by ACP clients, `serve`, and daemon hosts. ACP declarations are opt-in per request; ordinary clients without them, internal background dispatches, and all channel messages (including human messages) do not trigger recall. See [UserPromptSubmit](../../docs/users/features/hooks.md#userpromptsubmit) for the current field contract. This does not expand this Direct Profile's supported launchers. The original [Direct Auto Recall design](../../docs/design/direct-external-context-auto-recall.md) describes its TTY deployment constraints; its producer enumeration predates subsequent headless and ACP support. + +Missing or invalid provenance fails closed before configuration or credentials are read. Common credential shapes are removed from the submitted text, but this is not DLP. 1. Copy `examples/auto-recall-mem0.json` or `examples/auto-recall-generic-http.json` to an diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index dc4c9fef28a..7ced5e2ba6e 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -15823,6 +15823,41 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('forwards only explicitly declared submission text from trusted context', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const req = { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'machine wrapper' }], + _meta: { + 'qwen.submittedPrompt': 'forged public declaration', + 'qwen.daemon.submittedPrompt': 'forged private declaration', + }, + } as PromptRequest; + await bridge.sendPrompt(session.sessionId, req); + expect( + handle.agent.promptCalls[0]?._meta?.['qwen.daemon.submittedPrompt'], + ).toBeUndefined(); + expect( + handle.agent.promptCalls[0]?._meta?.['qwen.submittedPrompt'], + ).toBeUndefined(); + await bridge.sendPrompt(session.sessionId, req, undefined, { + submittedPrompt: ' original question\n', + }); + expect( + handle.agent.promptCalls[1]?._meta?.['qwen.daemon.submittedPrompt'], + ).toBe(' original question\n'); + await bridge.sendPrompt(session.sessionId, req, undefined, { + submittedPrompt: 'human channel message', + channelPrompt: true, + }); + expect( + handle.agent.promptCalls[2]?._meta?.['qwen.daemon.submittedPrompt'], + ).toBeUndefined(); + await bridge.shutdown(); + }); + it('strips spoofed channel-prompt classification and injects only trusted context', async () => { // `qwen.channel.prompt` opts a turn out of loop-detected rejection, // so a forged key must not reach the child; only the authenticated diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 054ffcf63df..f49dab0296e 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -167,6 +167,8 @@ import { DAEMON_ATTACHMENT_REFERENCES_META_KEY, DAEMON_MODEL_PROMPT_META_KEY, DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, + DAEMON_SUBMITTED_PROMPT_META_KEY, + SUBMITTED_PROMPT_META_KEY, DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY, DAEMON_SUPPRESS_RESTORE_ASK_USER_QUESTION_META_KEY, DAEMON_SUPPRESS_WORKTREE_CONTEXT_RESTORE_META_KEY, @@ -10530,6 +10532,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { delete meta[DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY]; delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY]; delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + delete meta[SUBMITTED_PROMPT_META_KEY]; + delete meta[DAEMON_SUBMITTED_PROMPT_META_KEY]; + if ( + typeof context?.submittedPrompt === 'string' && + !isPromotedMidTurn && + context?.channelPrompt !== true + ) { + meta[DAEMON_SUBMITTED_PROMPT_META_KEY] = + context.submittedPrompt; + } delete meta[DAEMON_MODEL_PROMPT_META_KEY]; delete meta[DAEMON_ATTACHMENT_REFERENCES_META_KEY]; // Channel classification is authenticated channel-worker diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 461dc08d243..4687d58f850 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -973,6 +973,8 @@ export interface BridgeClientRequestContext { * unchanged. HTTP routes never populate this from request input. */ modelPrompt?: string; + /** Original text explicitly declared by a supported submission producer. */ + submittedPrompt?: string; /** User-facing projection supplied by an authenticated channel worker. */ promptDisplayText?: string; /** @@ -1052,6 +1054,9 @@ export function isValidTrustedModelPrompt(value: unknown): value is string { } export const DAEMON_CHANNEL_DELIVERY_META_KEY = 'qwen.daemon.channelDelivery'; +export const SUBMITTED_PROMPT_META_KEY = 'qwen.submittedPrompt'; +export const DAEMON_SUBMITTED_PROMPT_META_KEY = 'qwen.daemon.submittedPrompt'; + export const DAEMON_PROMPT_DISPLAY_TEXT_META_KEY = 'qwen.daemon.promptDisplayText'; // Wire twin of channel-base's CHANNEL_PROMPT_META_KEY; the packages have no diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 0f2c8c29ae3..297616e4762 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -2937,6 +2937,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 'qwen-code/private-parent-capability': 'forged-capability', 'qwen.daemon.modelPrompt': 'forged model-only prompt', 'qwen.daemon.promptDisplayText': 'forged display text', + 'qwen.daemon.submittedPrompt': 'forged submission', }, }); @@ -2954,6 +2955,57 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('admits an explicit public submission without trusting private ACP metadata', async () => { + await setupSessionMocks('untrusted-session'); + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.initialize({ clientCapabilities: {} }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await agent.prompt({ + sessionId: 'untrusted-session', + prompt: [{ type: 'text', text: 'hello' }], + _meta: { + keep: true, + 'qwen.submittedPrompt': ' original question\n', + 'qwen-code/invocation': { + version: 1, + sessionId: 'forged-session', + promptId: 'forged-prompt', + }, + 'qwen-code/private-parent-capability': 'forged-capability', + 'qwen.daemon.modelPrompt': 'forged model-only prompt', + 'qwen.daemon.promptDisplayText': 'forged display text', + 'qwen.daemon.submittedPrompt': 'forged submission', + }, + }); + + expect(lastSessionMock?.prompt).toHaveBeenCalledWith( + { + sessionId: 'untrusted-session', + prompt: [{ type: 'text', text: 'hello' }], + _meta: { + keep: true, + 'qwen.daemon.submittedPrompt': ' original question\n', + }, + }, + undefined, + expect.any(AbortSignal), + undefined, + ); + mockConnectionState.resolve(); + await agentPromise; + }); + it('returns the startup profile only when initialize metadata requests v1', async () => { initializeAcpStartupProfiler(); const mockSettings = { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 2fbfa3616f1..4640096fc2e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -422,6 +422,8 @@ import { DAEMON_CHANNEL_DELIVERY_META_KEY, DAEMON_MODEL_PROMPT_META_KEY, DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, + DAEMON_SUBMITTED_PROMPT_META_KEY, + SUBMITTED_PROMPT_META_KEY, DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY, DAEMON_SUPPRESS_RESTORE_ASK_USER_QUESTION_META_KEY, DAEMON_SUPPRESS_WORKTREE_CONTEXT_RESTORE_META_KEY, @@ -6101,12 +6103,21 @@ class QwenAgent implements Agent { const suppliedContext = meta[INVOCATION_CONTEXT_META_KEY]; const suppliedModelPrompt = meta[DAEMON_MODEL_PROMPT_META_KEY]; const suppliedPromptDisplayText = meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + const submittedPrompt = + this.privateParentState === 'trusted' + ? meta[DAEMON_SUBMITTED_PROMPT_META_KEY] + : meta[SUBMITTED_PROMPT_META_KEY]; const suppliedChannelPrompt = meta[CHANNEL_PROMPT_META_KEY]; const suppliedChannelDelivery = meta[DAEMON_CHANNEL_DELIVERY_META_KEY]; delete meta[INVOCATION_CONTEXT_META_KEY]; delete meta[DAEMON_MODEL_PROMPT_META_KEY]; delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY]; delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + delete meta[SUBMITTED_PROMPT_META_KEY]; + delete meta[DAEMON_SUBMITTED_PROMPT_META_KEY]; + if (typeof submittedPrompt === 'string' && suppliedChannelPrompt !== true) { + meta[DAEMON_SUBMITTED_PROMPT_META_KEY] = submittedPrompt; + } delete meta[CHANNEL_PROMPT_META_KEY]; delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY]; // The user-facing display projection is caller-controlled metadata; honor diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 18edf8f55c5..c6bcec43519 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -30955,6 +30955,182 @@ describe('Session', () => { ); }); + it.each<{ + name: string; + prompt: PromptRequest['prompt']; + submitted?: string; + declared?: unknown; + displayText?: string; + modelPrompt?: string; + retry?: boolean; + metaRetry?: boolean; + channel?: boolean; + }>([ + { + name: 'text blocks without resource bodies', + prompt: [ + { type: 'text', text: ' check' }, + { + type: 'resource', + resource: { + uri: 'file:///notes.txt', + text: 'PRIVATE RESOURCE', + }, + }, + { type: 'text', text: 'this\n' }, + ], + submitted: ' check this\n', + declared: ' check this\n', + }, + { + name: 'explicit submission overrides unrelated display text', + prompt: [{ type: 'text', text: 'internal channel instructions' }], + displayText: 'display label', + submitted: 'original question', + declared: 'original question', + }, + { + name: 'display projection cannot declare submission provenance', + prompt: [{ type: 'text', text: 'internal channel instructions' }], + displayText: 'display text without a declaration', + }, + { + name: 'model-only delegation excluded', + prompt: [{ type: 'text', text: 'original question' }], + modelPrompt: + 'private model context', + submitted: 'original question', + declared: 'original question', + }, + { name: 'blank text', prompt: [{ type: 'text', text: ' \n ' }] }, + { + name: 'resource-only submission', + prompt: [ + { + type: 'resource', + resource: { + uri: 'file:///notes.txt', + text: 'PRIVATE RESOURCE', + }, + }, + ], + }, + { + name: 'legacy retry', + prompt: [{ type: 'text', text: 'retry question' }], + retry: true, + declared: 'retry question', + }, + { + name: 'daemon retry', + prompt: [{ type: 'text', text: 'retry question' }], + metaRetry: true, + declared: 'retry question', + }, + { + name: 'channel turn with display projection', + prompt: [{ type: 'text', text: 'composed channel wrapper' }], + displayText: 'Issue assigned: broken build', + channel: true, + declared: 'human channel message', + }, + { + name: 'channel turn without display projection', + prompt: [{ type: 'text', text: 'composed channel wrapper' }], + channel: true, + declared: 'human channel message', + }, + { + name: 'machine dispatch without a declaration', + prompt: [{ type: 'text', text: 'Run this scheduled task now' }], + }, + { + name: 'empty declaration never falls back to request text', + prompt: [{ type: 'text', text: 'internal wrapper' }], + declared: '', + }, + { + name: 'blank declaration', + prompt: [{ type: 'text', text: 'internal wrapper' }], + declared: ' \n', + }, + { + name: 'invalid declaration', + prompt: [{ type: 'text', text: 'internal wrapper' }], + declared: { text: 'not a string' }, + }, + ])( + 'preserves submission provenance: $name', + async ({ + prompt, + submitted, + declared, + displayText, + modelPrompt, + retry, + metaRetry, + channel, + }) => { + const messageBus = { + request: vi.fn().mockResolvedValue({ success: true, output: {} }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation( + (eventName: string) => eventName === 'UserPromptSubmit', + ); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt( + { + sessionId: 'test-session-id', + prompt, + ...(retry ? { retry: true } : {}), + _meta: { + ...(declared !== undefined + ? { 'qwen.daemon.submittedPrompt': declared } + : {}), + ...(channel ? { [CHANNEL_PROMPT_META_KEY]: true } : {}), + ...(displayText !== undefined + ? { 'qwen.daemon.promptDisplayText': displayText } + : {}), + ...(metaRetry ? { 'qwen.daemon.retry': true } : {}), + }, + } as PromptRequest, + modelPrompt === undefined + ? undefined + : { + version: 1, + sessionId: 'test-session-id', + promptId: 'daemon-prompt-id', + }, + undefined, + modelPrompt, + ); + + expect(mockChat.sendMessageStream).toHaveBeenCalledOnce(); + expect(messageBus.request).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: 'UserPromptSubmit', + input: { + prompt: prompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '), + ...(submitted === undefined + ? {} + : { submitted_prompt: submitted }), + }, + }), + expect.anything(), + ); + }, + ); + it('blocks prompt when UserPromptSubmit hook returns blocking decision', async () => { const messageBus = { request: vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d70aa050a71..5996b89e6ae 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -256,6 +256,7 @@ import { DAEMON_ATTACHMENT_REFERENCES_META_KEY, DAEMON_PERMISSION_CANCEL_REASON_META_KEY, DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, + DAEMON_SUBMITTED_PROMPT_META_KEY, DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY, MID_TURN_QUEUE_DRAIN_METHOD, isValidTrustedModelPrompt, @@ -5301,6 +5302,10 @@ export class Session implements SessionContext { typeof promptDisplayTextValue === 'string' ? promptDisplayTextValue : undefined; + const declaredSubmission = + promptMetadata?.[DAEMON_SUBMITTED_PROMPT_META_KEY]; + const submittedPrompt = + typeof declaredSubmission === 'string' ? declaredSubmission : ''; const modelPromptBlocks: PromptRequest['prompt'] = modelPrompt === undefined ? params.prompt @@ -5612,6 +5617,10 @@ export class Session implements SessionContext { !isContinue && !isRestoreAskUserQuestion && !isRuntimeContinuation; + // Channel markers cover both automated and human messages. Keep + // that class excluded until its producers distinguish them; see + // docs/design/daemon-user-prompt-submit-provenance.md. + const isUserSubmissionTurn = isFreshUserTurn && !channelTurn; if ( !isContinue && !isRestoreAskUserQuestion && @@ -5629,6 +5638,10 @@ export class Session implements SessionContext { eventName: 'UserPromptSubmit', input: { prompt: promptText, + ...(isUserSubmissionTurn && + submittedPrompt.trim().length > 0 + ? { submitted_prompt: submittedPrompt } + : {}), }, signal: pendingSend.signal, }, diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 97770a5c4d6..31c9a09de52 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -852,8 +852,8 @@ export default { "L'entrada a l'ordre és JSON amb tool_name, tool_input, tool_use_id, error, error_type, is_interrupt i is_timeout.", 'Input to command is JSON with notification message and type.': "L'entrada a l'ordre és JSON amb el missatge de notificació i el tipus.", - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'L’entrada de l’ordre és JSON amb "prompt" (el prompt actual vinculat al model) i el camp opcional "submitted_prompt" (la projecció de text de la TUI interactiva compatible).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'L’entrada de l’ordre és JSON amb "prompt" (el prompt actual vinculat al model) i el camp opcional "submitted_prompt" (la projecció de text capturada en un límit d’enviament compatible).', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': "L'entrada a l'ordre és JSON amb command_name, command_args i el text del missatge expandit.", 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 3a6571ddda9..41ad4194b64 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -798,8 +798,8 @@ export default { 'Die Eingabe an den Befehl ist JSON mit tool_name, tool_input, tool_use_id, error, error_type, is_interrupt und is_timeout.', 'Input to command is JSON with notification message and type.': 'Die Eingabe an den Befehl ist JSON mit Benachrichtigungsnachricht und -typ.', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'Die Eingabe für den Befehl ist JSON mit "prompt" (dem aktuellen modellgebundenen Prompt) und optional "submitted_prompt" (der Textprojektion der unterstützten interaktiven TUI).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'Die Eingabe für den Befehl ist JSON mit "prompt" (dem aktuellen modellgebundenen Prompt) und optional "submitted_prompt" (der an einer unterstützten Übermittlungsgrenze erfassten Textprojektion).', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': 'Die Eingabe an den Befehl ist JSON mit command_name, command_args und erweitertem Prompt-Text.', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index ae13ebf1b51..f8167e7ef98 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1155,8 +1155,8 @@ export default { 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.', 'Input to command is JSON with notification message and type.': 'Input to command is JSON with notification message and type.', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': 'Input to command is JSON with command_name, command_args, and expanded prompt text.', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index d49db98060b..60be7043f11 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -867,8 +867,8 @@ export default { "L'entrée de la commande est du JSON avec tool_name, tool_input, tool_use_id, error, error_type, is_interrupt et is_timeout.", 'Input to command is JSON with notification message and type.': "L'entrée de la commande est du JSON avec le message et le type de notification.", - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'L’entrée de la commande est un JSON avec "prompt" (l’invite actuelle liée au modèle) et, facultativement, "submitted_prompt" (la projection textuelle de l’interface TUI interactive prise en charge).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'L’entrée de la commande est un JSON avec "prompt" (l’invite actuelle liée au modèle) et, facultativement, "submitted_prompt" (la projection textuelle capturée à une frontière de soumission prise en charge).', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': "L'entrée de la commande est du JSON avec command_name, command_args et le texte d'invite développé.", 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index fd23486d7ab..f68a7945c9a 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -583,8 +583,8 @@ export default { 'コマンドへの入力は tool_name、tool_input、tool_use_id、error、error_type、is_interrupt、is_timeout を持つ JSON です。', 'Input to command is JSON with notification message and type.': 'コマンドへの入力は通知メッセージとタイプを持つ JSON です。', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'コマンド入力は、"prompt"(現在のモデル向けプロンプト)と、オプションの "submitted_prompt"(サポート対象の対話型 TUI で入力されたテキストの投影)を含む JSON です。', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'コマンド入力は、"prompt"(現在のモデル向けプロンプト)と、オプションの "submitted_prompt"(サポート対象の送信境界でキャプチャされたテキスト投影)を含む JSON です。', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': 'コマンドへの入力は command_name、command_args、展開後のプロンプトテキストを持つ JSON です。', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index f0342f5446b..3f1bd4bebd5 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -804,8 +804,8 @@ export default { 'A entrada para o comando é JSON com tool_name, tool_input, tool_use_id, error, error_type, is_interrupt e is_timeout.', 'Input to command is JSON with notification message and type.': 'A entrada para o comando é JSON com mensagem e tipo de notificação.', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'A entrada para o comando é JSON com "prompt" (o prompt atual vinculado ao modelo) e o campo opcional "submitted_prompt" (a projeção de texto da TUI interativa compatível).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'A entrada para o comando é JSON com "prompt" (o prompt atual vinculado ao modelo) e o campo opcional "submitted_prompt" (a projeção de texto capturada em um ponto de envio compatível).', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': 'A entrada para o comando é JSON com command_name, command_args e o texto do prompt expandido.', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 315ad32b041..6c51e31be4a 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -811,8 +811,8 @@ export default { 'Ввод в команду — это JSON с tool_name, tool_input, tool_use_id, error, error_type, is_interrupt и is_timeout.', 'Input to command is JSON with notification message and type.': 'Ввод в команду — это JSON с сообщением уведомления и типом.', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - 'Ввод команды — JSON с полем "prompt" (текущий промпт, отправляемый модели) и необязательным "submitted_prompt" (текстовая проекция поддерживаемого интерактивного TUI).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + 'Ввод команды — JSON с полем "prompt" (текущий промпт, отправляемый модели) и необязательным "submitted_prompt" (текстовая проекция, захваченная на поддерживаемой границе отправки).', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': 'Ввод в команду — это JSON с command_name, command_args и развернутым текстом промпта.', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index b105408da96..44c117e1a0f 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1064,8 +1064,8 @@ export default { '命令輸入為包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。', 'Input to command is JSON with notification message and type.': '命令輸入為包含通知消息和類型的 JSON。', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - '命令輸入為 JSON,其中包含 "prompt"(目前模型側提示)以及選用的 "submitted_prompt"(受支援互動式 TUI 的提交文字投影)。', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + '命令輸入為 JSON,其中包含 "prompt"(目前模型側提示)以及選用的 "submitted_prompt"(在受支援的提交邊界擷取的文字投影)。', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': '命令輸入為包含 command_name、command_args 和展開後提示文本的 JSON。', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 13512ee2de7..00817ceb872 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1124,8 +1124,8 @@ export default { '命令输入为包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。', 'Input to command is JSON with notification message and type.': '命令输入为包含通知消息和类型的 JSON。', - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).': - '命令输入为 JSON,其中包含 "prompt"(当前模型侧提示)以及可选的 "submitted_prompt"(受支持交互式 TUI 的提交文本投影)。', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).': + '命令输入为 JSON,其中包含 "prompt"(当前模型侧提示)以及可选的 "submitted_prompt"(在受支持的提交边界捕获的文本投影)。', 'Input to command is JSON with command_name, command_args, and expanded prompt text.': '命令输入为包含 command_name、command_args 和展开后提示文本的 JSON。', 'Input to command is JSON with session start source.': diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index daa01a8074a..ae3e4aa6aa3 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -53,10 +53,14 @@ import { } from '../auth/device-flow.js'; import { REQUESTED_SESSION_ID_META_KEY, + SUBMITTED_PROMPT_META_KEY, + CHANNEL_PROMPT_META_KEY, + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, type BridgeBranchedSession, type BridgeRestoredSession, type HttpAcpBridge, } from '@qwen-code/acp-bridge/bridgeTypes'; +import { CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY } from '../channel-worker-prompt-authorization.js'; import { parseSessionSource } from '@qwen-code/acp-bridge'; import { restoreRetryAfterSeconds } from '@qwen-code/acp-bridge/sessionRestoreTimeout'; import { @@ -5851,6 +5855,8 @@ export class AcpDispatcher { binding.promptAbort?.abort(); const abort = new AbortController(); binding.promptAbort = abort; + const metadata = params['_meta'] as Record | undefined; + const submittedPrompt = metadata?.[SUBMITTED_PROMPT_META_KEY]; try { const result = await this.bridge.sendPrompt( sessionId, @@ -5862,7 +5868,15 @@ export class AcpDispatcher { // sessionId, prompt }`) so it can't become client-controlled. params as unknown as Parameters[1], abort.signal, - this.sessionCtx(conn, sessionId, fromLoopback), + { + ...this.sessionCtx(conn, sessionId, fromLoopback), + ...(typeof submittedPrompt === 'string' && + metadata?.[CHANNEL_PROMPT_META_KEY] === undefined && + metadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === undefined && + metadata?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY] === undefined + ? { submittedPrompt } + : {}), + }, ); if (id !== undefined) this.replySession(conn, sessionId, id, result); } catch (err) { diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index afe27e9c0ac..3df523d8cb4 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -1862,6 +1862,54 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it.each([ + { meta: undefined, context: {} }, + { meta: { 'qwen.daemon.submittedPrompt': 'forged' }, context: {} }, + { + meta: { + 'qwen.submittedPrompt': 'label', + 'qwen.daemon.channelPromptAuthorization': 'revoked-worker', + }, + context: {}, + }, + { + meta: { 'qwen.submittedPrompt': ' original text\n' }, + context: { submittedPrompt: ' original text\n' }, + }, + ])( + 'admits only public submission declarations over ACP HTTP: $meta', + async ({ meta, context }) => { + const send = vi.spyOn(bridge, 'sendPrompt'); + const connId = await initialize(); + await newSession(connId); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 5, + method: 'session/prompt', + params: { + sessionId: 'sess-1', + prompt: [{ type: 'text', text: 'wrapper' }], + ...(meta ? { _meta: meta } : {}), + }, + }); + expect(ack.status).toBe(202); + await vi.waitFor(() => expect(send).toHaveBeenCalled()); + expect(send).toHaveBeenCalledWith( + 'sess-1', + expect.anything(), + expect.any(AbortSignal), + expect.objectContaining({ + ...context, + }), + ); + if (Object.keys(context).length === 0) { + expect((send.mock.calls[0] as unknown[])[3]).not.toHaveProperty( + 'submittedPrompt', + ); + } + }, + ); + it('prompt streams session/update then the final result', async () => { bridge.promptBehavior = async (_s, q) => { q.push({ diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 86dd17ef87d..51adf77e063 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -50,6 +50,8 @@ import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifact import { CHANNEL_PROMPT_META_KEY, DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, + DAEMON_SUBMITTED_PROMPT_META_KEY, + SUBMITTED_PROMPT_META_KEY, type BridgeBranchedSession, } from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; @@ -6901,6 +6903,7 @@ export function registerSessionRoutes( !Array.isArray(forwardedBody['_meta']) ? { ...(forwardedBody['_meta'] as Record) } : undefined; + const submittedPrompt = forwardedMeta?.[SUBMITTED_PROMPT_META_KEY]; const promptAuthorization = forwardedMeta?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]; const promptDisplayText = @@ -6909,6 +6912,8 @@ export function registerSessionRoutes( if (forwardedMeta) { delete forwardedMeta[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]; delete forwardedMeta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + delete forwardedMeta[SUBMITTED_PROMPT_META_KEY]; + delete forwardedMeta[DAEMON_SUBMITTED_PROMPT_META_KEY]; delete forwardedMeta[CHANNEL_PROMPT_META_KEY]; if (Object.keys(forwardedMeta).length > 0) { forwardedBody['_meta'] = forwardedMeta; @@ -6979,6 +6984,12 @@ export function registerSessionRoutes( ...(trustedPromptDisplayText !== undefined ? { promptDisplayText: trustedPromptDisplayText } : {}), + ...(typeof submittedPrompt === 'string' && + channelPrompt === undefined && + promptAuthorization === undefined && + promptDisplayText === undefined + ? { submittedPrompt } + : {}), ...(trustedChannelPrompt ? { channelPrompt: true } : {}), ...(delivery !== undefined ? { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 9283e44c416..c2457a10a28 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -18617,6 +18617,62 @@ describe('createServeApp', () => { } }); + it('requires explicit submission provenance and never upgrades a rejected worker', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const cases = [ + { meta: undefined, expected: undefined }, + { + meta: { 'qwen.daemon.submittedPrompt': 'forged' }, + expected: undefined, + }, + { + meta: { 'qwen.submittedPrompt': ' original question\n' }, + expected: ' original question\n', + }, + { meta: { 'qwen.submittedPrompt': 42 }, expected: undefined }, + { + meta: { + 'qwen.submittedPrompt': 'label', + [CHANNEL_PROMPT_META_KEY]: true, + }, + expected: undefined, + }, + { + meta: { + 'qwen.submittedPrompt': 'label', + [CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'revoked-worker', + }, + expected: undefined, + }, + { + meta: { + 'qwen.submittedPrompt': 'label', + 'qwen.daemon.promptDisplayText': 'label', + }, + expected: undefined, + }, + ]; + for (const { meta, expected } of cases) { + const result = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + prompt: [{ type: 'text', text: 'request wrapper' }], + ...(meta ? { _meta: meta } : {}), + }); + expect(result.status).toBe(202); + const call = bridge.promptCalls.at(-1); + expect(call?.context?.submittedPrompt).toBe(expected); + expect(call?.req._meta ?? {}).not.toHaveProperty( + 'qwen.submittedPrompt', + ); + expect(call?.req._meta ?? {}).not.toHaveProperty( + 'qwen.daemon.submittedPrompt', + ); + } + }); + it('accepts channel-prompt classification only from the workspace worker', async () => { // `qwen.channel.prompt` opts a turn out of loop-detected rejection; // a forged key from an unauthorized caller must be dropped at the diff --git a/packages/cli/src/ui/components/hooks/constants.test.ts b/packages/cli/src/ui/components/hooks/constants.test.ts index 2d19c5afa37..76276273234 100644 --- a/packages/cli/src/ui/components/hooks/constants.test.ts +++ b/packages/cli/src/ui/components/hooks/constants.test.ts @@ -235,7 +235,7 @@ describe('hooks constants', () => { expect(desc).toContain('"prompt"'); expect(desc).toContain('model-bound'); expect(desc).toContain('"submitted_prompt"'); - expect(desc).toContain('interactive TUI'); + expect(desc).toContain('submission boundary'); }); it('should return description for PostCompact', () => { diff --git a/packages/cli/src/ui/components/hooks/constants.ts b/packages/cli/src/ui/components/hooks/constants.ts index 01ae8d3937c..d6a465174d7 100644 --- a/packages/cli/src/ui/components/hooks/constants.ts +++ b/packages/cli/src/ui/components/hooks/constants.ts @@ -244,7 +244,7 @@ export function getHookDescription(eventName: string): string { 'Input to command is JSON with file_path, memory_type, load_reason, and optional trigger_file_path and parent_file_path.', ), [HookEventName.UserPromptSubmit]: t( - 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).', + 'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).', ), [HookEventName.UserPromptExpansion]: t( 'Input to command is JSON with command_name, command_args, and expanded prompt text.', diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 8e54f489ef5..c50f2421b13 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -439,8 +439,9 @@ export interface NotificationRecordPayload { export interface UserPromptRecordPayload { /** - * TUI submittedPrompt projection when available; otherwise the expanded - * pre-hook prompt. + * Core/headless: submitted projection, otherwise expanded pre-hook text. + * ACP: display projection or raw request text before expansion. ACP omits + * this payload when neither a projection nor attachment references exist. */ displayText: string; /** Sanitized hook context duplicated from the tagged model-bound part. */ diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index aa64842db58..6d106083f42 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -20534,7 +20534,7 @@ describe('App session callbacks', () => { }); expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( 'resolved', - expect.objectContaining({ inputAnnotations }), + expect.objectContaining({ inputAnnotations, submittedPrompt: 'hello' }), ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -20967,6 +20967,8 @@ describe('App session callbacks', () => { undefined, undefined, undefined, + undefined, + '', ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -24674,6 +24676,8 @@ describe('App session callbacks', () => { undefined, undefined, undefined, + undefined, + 'queued', ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -24726,6 +24730,8 @@ describe('App session callbacks', () => { undefined, undefined, inputAnnotations, + undefined, + 'queued', ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -26296,20 +26302,36 @@ describe('App session callbacks', () => { }, ); - it('converts /skills arguments to a direct skill command', async () => { - const { container } = renderApp(); - await flush(); - - testState.prompt = '/skills bugfix'; - await clickSubmit(container); - await flush(); - - expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( - '/bugfix', - expect.any(Object), - ); - expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); - }); + it.each(['idle', 'responding'] as const)( + 'preserves the original /skills submission when %s', + async (streamingState) => { + testState.streamingState = streamingState; + const { container } = renderApp(); + await flush(); + testState.prompt = '/skills bugfix'; + await clickSubmit(container); + await flush(); + if (streamingState === 'idle') { + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + '/bugfix', + expect.objectContaining({ submittedPrompt: '/skills bugfix' }), + ); + } else { + expect(rawEnqueuePrompt).toHaveBeenCalledWith( + '/bugfix', + undefined, + undefined, + undefined, + undefined, + undefined, + '/skills bugfix', + ); + } + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).toBeNull(); + }, + ); it('opens plugin management tabs from the sidebar', async () => { mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ @@ -35867,6 +35889,10 @@ describe('App manual-run orchestration (scheduled tasks)', () => { await act(async () => { await expect(run('do the thing', null)).resolves.toBeUndefined(); }); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + 'do the thing', + expect.not.objectContaining({ submittedPrompt: expect.anything() }), + ); }); it('rejects an unbound run that settles without admitting (cancel path)', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6e2294dc8fe..d12e6e2a3fb 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -616,6 +616,7 @@ function resolvePreparedSubmit( } interface SendPromptOptionsWithRetry { + submittedPrompt?: string; optimisticUserMessage?: boolean; images?: PromptImage[]; files?: PromptFile[]; @@ -9536,6 +9537,7 @@ export function App({ // by the failed-prompt retry, whose user message was never // recorded. skipPrepareSubmit?: boolean; + submittedPrompt?: string; inputAnnotations?: DaemonInputAnnotation[]; clearComposerOnPromptStart?: boolean; commitComposerAccepted?: ComposerSubmitCommit; @@ -9748,6 +9750,9 @@ export function App({ let admissionStarted = false; let admitted = false; const promptOptions: SendPromptOptionsWithRetry = { + ...(opts?.submittedPrompt !== undefined + ? { submittedPrompt: opts.submittedPrompt } + : {}), images, files, inputAnnotations: @@ -10300,6 +10305,7 @@ export function App({ onComplete?: () => void, commitComposerAccepted?: ComposerSubmitCommit, inputAnnotations?: DaemonInputAnnotation[], + submittedPrompt = text, ) => { const normalizedInputAnnotations = inputAnnotations ? [...inputAnnotations] @@ -10326,6 +10332,8 @@ export function App({ files, onComplete, annotations, + undefined, + submittedPrompt, ); if (result !== false) { if (commitComposerAccepted) { @@ -13967,6 +13975,7 @@ export function App({ undefined, commitComposerAccepted, metadata?.inputAnnotations, + text, ); }; const submitPromptFromEditor = ( @@ -14009,6 +14018,7 @@ export function App({ let admissionStarted = false; let admissionSessionId: string | undefined; sendPrompt(promptText, promptImages, promptFiles, { + submittedPrompt: text, ownerRef: admissionAttachment, ...sendOptions, clearComposerOnPromptStart, @@ -14485,6 +14495,7 @@ export function App({ writeBlockGeneration ) { return sendPrompt(prompt, images, files, { + submittedPrompt: text, clearComposerOnPromptStart: true, inputAnnotations: metadata?.inputAnnotations, }); diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 22560d9c11d..b48b6600929 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -1866,6 +1866,7 @@ describe('ChatPane', () => { ); expect(sendPrompt).toHaveBeenCalledTimes(1); expect(sendPrompt).toHaveBeenCalledWith('hello there', { + submittedPrompt: 'hello there', onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), }); @@ -2021,6 +2022,7 @@ describe('ChatPane', () => { expect(onSlashCommand).toHaveBeenCalledTimes(1); expect(sendPrompt).toHaveBeenCalledWith('/deploy staging', { + submittedPrompt: '/deploy staging', onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), }); @@ -2044,6 +2046,7 @@ describe('ChatPane', () => { undefined, undefined, expect.any(Function), + '/deploy staging', ); }); @@ -2077,6 +2080,7 @@ describe('ChatPane', () => { 'onSlashCommand callback failed', ); expect(sendPrompt).toHaveBeenCalledWith('/deploy staging', { + submittedPrompt: '/deploy staging', onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), }); @@ -2092,6 +2096,7 @@ describe('ChatPane', () => { expect(onSlashCommand).not.toHaveBeenCalled(); expect(sendPrompt).toHaveBeenCalledWith('/usr/local/bin/tool', { + submittedPrompt: '/usr/local/bin/tool', onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), }); @@ -2157,6 +2162,7 @@ describe('ChatPane', () => { latestOnSubmit!('with image', images); }); expect(sendPrompt).toHaveBeenCalledWith('with image', { + submittedPrompt: 'with image', images, onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), @@ -2172,6 +2178,7 @@ describe('ChatPane', () => { latestOnSubmit!('', images); }); expect(sendPrompt).toHaveBeenCalledWith('', { + submittedPrompt: '', images, onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), @@ -2204,6 +2211,7 @@ describe('ChatPane', () => { }); }); expect(sendPrompt).toHaveBeenCalledWith('check @.husky/', { + submittedPrompt: 'check @.husky/', inputAnnotations, onAdmissionStarted: expect.any(Function), onAdmitted: expect.any(Function), @@ -2226,6 +2234,7 @@ describe('ChatPane', () => { undefined, undefined, expect.any(Function), + 'queued next', ); expect(catalogController.invalidateWorkspace).toHaveBeenCalledWith('/w'); expect(sendPrompt).not.toHaveBeenCalled(); @@ -2344,6 +2353,7 @@ describe('ChatPane', () => { undefined, inputAnnotations, expect.any(Function), + 'queue @.husky/', ); expect(sendPrompt).not.toHaveBeenCalled(); }); @@ -2362,6 +2372,7 @@ describe('ChatPane', () => { undefined, undefined, expect.any(Function), + 'queued image', ); }); @@ -2374,7 +2385,15 @@ describe('ChatPane', () => { latestOnSubmit!('', images); }); - expect(enqueuePrompt).toHaveBeenCalledWith('', images, undefined); + expect(enqueuePrompt).toHaveBeenCalledWith( + '', + images, + undefined, + undefined, + undefined, + undefined, + '', + ); expect(sendPrompt).not.toHaveBeenCalled(); }); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index deae2354542..b39c93e7280 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -1014,6 +1014,7 @@ export function ChatPane({ const submit = () => actions .sendPrompt(trimmed, { + submittedPrompt: text, ...(images && images.length ? { images } : {}), ...(files && files.length ? { files } : {}), ...(inputAnnotations ? { inputAnnotations } : {}), @@ -1092,7 +1093,15 @@ export function ChatPane({ } const queued = !trimmed && !inputAnnotations - ? enqueuePrompt(trimmed, images, files) + ? enqueuePrompt( + trimmed, + images, + files, + undefined, + undefined, + undefined, + text, + ) : enqueuePrompt( trimmed, images, @@ -1100,6 +1109,7 @@ export function ChatPane({ undefined, inputAnnotations, notifyFirstPromptAdmitted, + text, ); if (queued !== false && catalogOwnerCwd) { sessionCatalogController.invalidateWorkspace(catalogOwnerCwd); diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.tsx b/packages/web-shell/client/components/QueuedPromptDisplay.tsx index 3c7bb527a78..1b67c2f748d 100644 --- a/packages/web-shell/client/components/QueuedPromptDisplay.tsx +++ b/packages/web-shell/client/components/QueuedPromptDisplay.tsx @@ -123,6 +123,7 @@ function truncateQueuedPromptParts(parts: readonly QueuedPromptPreviewPart[]): { } export interface QueuedPrompt { + submittedPrompt?: string; id: number; sessionId?: string; text: string; diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index 9beab3d6560..ee6f971027d 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -24,6 +24,27 @@ import type { } from './types'; describe('getConnectionAfterSessionClear', () => { + it.each(['sendPrompt', 'submitPrompt'] as const)( + 'preserves declared text before host and attachment expansion through %s', + async (method) => { + const session = createMockSession('session-a'); + const { actions } = createActionsHarness({ session }); + const pending = actions[method]('host-expanded request', { + submittedPrompt: ' original question\n', + }); + await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled()); + expect(session.submitPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: [{ type: 'text', text: 'host-expanded request' }], + _meta: { 'qwen.submittedPrompt': ' original question\n' }, + }), + ...(method === 'sendPrompt' ? [expect.any(AbortSignal)] : []), + ); + if (method === 'sendPrompt') await actions.cancel(); + await pending; + }, + ); + it('clears session fields for the session being detached', () => { const next = getConnectionAfterSessionClear( { diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index c7060c80fc1..c36af3603c1 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -1120,8 +1120,13 @@ export function createDaemonSessionActions({ prompt: uploaded.content, }; options?.onAdmissionStarted?.(); - if (inputAnnotations) { - promptRequest['_meta'] = { inputAnnotations }; + if (inputAnnotations || typeof options?.submittedPrompt === 'string') { + promptRequest['_meta'] = { + ...(typeof options?.submittedPrompt === 'string' + ? { 'qwen.submittedPrompt': options.submittedPrompt } + : {}), + ...(inputAnnotations ? { inputAnnotations } : {}), + }; } if (options?.retry) { promptRequest['retry'] = true; @@ -1287,8 +1292,13 @@ export function createDaemonSessionActions({ const promptRequest: Record = { prompt: uploaded.content, }; - if (inputAnnotations) { - promptRequest['_meta'] = { inputAnnotations }; + if (inputAnnotations || typeof options?.submittedPrompt === 'string') { + promptRequest['_meta'] = { + ...(typeof options?.submittedPrompt === 'string' + ? { 'qwen.submittedPrompt': options.submittedPrompt } + : {}), + ...(inputAnnotations ? { inputAnnotations } : {}), + }; } if (options?.retry) { promptRequest['retry'] = true; diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 316cc2dfb70..5102cd4d0ad 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -344,6 +344,8 @@ export interface DaemonCommandInfo { } export interface SendPromptOptions { + /** Original text declared at the user submission boundary, before host preparation. */ + submittedPrompt?: string; optimisticUserMessage?: boolean; images?: DaemonPromptImage[]; files?: DaemonPromptFile[]; diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx index d0f2cca8ce0..6f2b33bd915 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx @@ -201,6 +201,43 @@ afterEach(() => { }); describe('useQueuedPrompts default mid-turn insertion', () => { + it.each([undefined, ' original question\n'])( + 'keeps an optional original submission separate from the queued payload: %s', + async (submittedPrompt) => { + const { actions } = createActions(); + const { render } = mount( + 'responding', + actions, + false, + false, + false, + true, + ); + act(() => + latest.enqueuePrompt( + 'host-expanded payload', + undefined, + undefined, + undefined, + undefined, + undefined, + submittedPrompt, + ), + ); + await act(async () => render('idle', 'session-1', false, false, false)); + expect(actions.submitPrompt).toHaveBeenCalledWith( + 'host-expanded payload', + expect.objectContaining({ sessionId: 'session-1' }), + ); + const options = vi.mocked(actions.submitPrompt).mock.calls[0]?.[1]; + if (submittedPrompt === undefined) { + expect(options).not.toHaveProperty('submittedPrompt'); + } else { + expect(options).toHaveProperty('submittedPrompt', submittedPrompt); + } + }, + ); + it('holds Goal follow-ups locally until an explicit insert', async () => { const { actions } = createActions(); vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({ diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index 32eddba5c02..8f8650511ca 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -256,6 +256,7 @@ function areQueuedPromptsEqual( prompt.isEditing === other.isEditing && prompt.isRemoving === other.isRemoving && prompt.payloadCompleteness === other.payloadCompleteness && + prompt.submittedPrompt === other.submittedPrompt && (prompt.images?.length ?? 0) === (other.images?.length ?? 0) && (prompt.files?.length ?? 0) === (other.files?.length ?? 0) && (prompt.inputAnnotations?.length ?? 0) === @@ -385,6 +386,7 @@ export interface UseQueuedPromptsResult { onComplete?: () => void, inputAnnotations?: DaemonInputAnnotation[], onAdmitted?: () => void, + submittedPrompt?: string, ) => boolean; removeQueuedPrompt: (id: number) => void; insertQueuedPrompt: (id: number) => Promise; @@ -619,7 +621,7 @@ export function useQueuedPrompts({ next[existingIndex] = { ...next[existingIndex]!, ...(next[existingIndex]!.payloadCompleteness === 'summary-only' - ? { text: serverPrompt.text } + ? { text: serverPrompt.text, submittedPrompt: undefined } : {}), // Restore images from server content if local row doesn't have // them; clearing summary-only makes the restored row editable. @@ -1389,6 +1391,9 @@ export function useQueuedPrompts({ return sessionActions .submitPrompt(prompt.text, { + ...(prompt.submittedPrompt !== undefined + ? { submittedPrompt: prompt.submittedPrompt } + : {}), images: prompt.images, files: prompt.files, inputAnnotations: prompt.inputAnnotations, @@ -1636,6 +1641,7 @@ export function useQueuedPrompts({ onComplete?: () => void, inputAnnotations?: DaemonInputAnnotation[], onAdmitted?: () => void, + submittedPrompt?: string, ) => { const trimmed = text.trim(); if (!trimmed && (images?.length ?? 0) === 0 && (files?.length ?? 0) === 0) @@ -1716,6 +1722,7 @@ export function useQueuedPrompts({ ...pendingAdmission, text: trimmed, files: fileList.length > 0 ? [...fileList] : undefined, + ...(submittedPrompt !== undefined ? { submittedPrompt } : {}), inputAnnotations: inputAnnotations ? [...inputAnnotations] : undefined, @@ -1991,6 +1998,7 @@ export function useQueuedPrompts({ } const prompt: QueuedPrompt = { + ...(submittedPrompt !== undefined ? { submittedPrompt } : {}), id: nextQueuedPromptIdRef.current++, sessionId: targetSessionId, text: trimmed,