From 32d22f950cf6f987eb8f06ac2722a02ca79fed2a Mon Sep 17 00:00:00 2001
From: jinye
Date: Fri, 6 Nov 2026 17:23:44 +0800
Subject: [PATCH] feat(managed-agent): support durable structured user
questions (#16433)
Connect Hosted question actions, structured answers, durable continuation, and the WebShell question form. Cover canonical response replay and pending-question recovery across Harness and Java context restarts.
Co-authored-by: Qwen-Coder
---
.../2026-11-06-managed-user-questions.md | 133 +++++
...2026-11-06-managed-user-questions.zh-CN.md | 108 ++++
.../src/serve/hosted-approval-continuation.ts | 33 +-
.../serve/hosted-approval-fallback.test.ts | 2 +
.../src/serve/hosted-harness-session.test.ts | 5 +
.../cli/src/serve/hosted-harness-session.ts | 14 +-
packages/cli/src/serve/hosted-plan-mode.ts | 9 +-
.../src/serve/hosted-runtime-recovery.test.ts | 2 +-
.../cli/src/serve/hosted-tool-approval.ts | 144 +++--
.../src/serve/hosted-user-question.test.ts | 562 ++++++++++++++++++
.../cli/src/serve/hosted-user-question.ts | 314 ++++++++++
.../src/serve/hosted-workspace-tool-turn.ts | 155 ++++-
packages/managed-agent-server/README.md | 37 ++
.../harness/QwenHostedHarnessConnector.java | 22 +-
.../ManagedHostedRecoveryRecords.java | 2 +-
.../persistence/ManagedUserQuestions.java | 184 ++++++
.../code/managedagent/model/ApiModels.java | 53 +-
.../repository/ManagedActionStore.java | 3 +
.../managed-agent-public-api.openapi.json | 116 ++--
.../session/ManagedActionService.java | 50 +-
.../persistence/JournalCommit.java | 2 +-
.../ManagedActionJournalValidation.java | 103 ++++
.../persistence/ManagedActionStore.java | 112 ++--
.../api/ManagedActionController.java | 4 +-
.../qwen/code/managedagent/ActionJournal.java | 57 +-
.../managedagent/HostedPublicWorkspaceIT.java | 85 ++-
.../code/managedagent/ManagedActionsTest.java | 83 +++
.../ManagedAgentApiContractTest.java | 4 +-
.../ManagedHostedRecoveryRecordsTest.java | 7 +-
.../persistence/ManagedUserQuestionsTest.java | 130 ++++
.../QwenHostedHarnessConnectorTest.java | 38 ++
.../resources/class-size-ratchet.properties | 2 +-
.../qwen/code/daemon/HostedHarnessClient.java | 28 +-
.../code/daemon/HostedHarnessClientTest.java | 22 +
packages/vscode-ide-companion/NOTICES.txt | 26 +
.../managed/ManagedQuestionForm.test.tsx | 167 ++++++
.../managed/ManagedQuestionForm.tsx | 208 +++++++
.../managed/ManagedSessionsPage.test.tsx | 76 ++-
.../managed/ManagedSessionsPage.tsx | 121 ++--
.../managed/generated/managed-agent-api.ts | 13 +-
.../java-managed-agent-provider.test.ts | 65 ++
.../managed/java-managed-agent-provider.ts | 22 +-
.../managed/managed-agent-provider.ts | 32 +-
.../components/managed/managed-approval.ts | 26 +-
.../managed/managed-question-response.test.ts | 79 +++
.../managed/managed-question-response.ts | 71 +++
.../managed/managed-question.test-fixtures.ts | 39 ++
.../managed/use-managed-actions.test.tsx | 99 +++
.../components/managed/use-managed-actions.ts | 43 +-
packages/web-shell/client/i18n.tsx | 31 +-
packages/web-shell/client/index.tsx | 5 +
packages/web-shell/package.json | 1 +
pnpm-lock.yaml | 3 +
53 files changed, 3436 insertions(+), 316 deletions(-)
create mode 100644 docs/design/2026-11-06-managed-user-questions.md
create mode 100644 docs/design/2026-11-06-managed-user-questions.zh-CN.md
create mode 100644 packages/cli/src/serve/hosted-user-question.test.ts
create mode 100644 packages/cli/src/serve/hosted-user-question.ts
create mode 100644 packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestions.java
create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionJournalValidation.java
create mode 100644 packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestionsTest.java
create mode 100644 packages/web-shell/client/components/managed/ManagedQuestionForm.test.tsx
create mode 100644 packages/web-shell/client/components/managed/ManagedQuestionForm.tsx
create mode 100644 packages/web-shell/client/components/managed/managed-question-response.test.ts
create mode 100644 packages/web-shell/client/components/managed/managed-question-response.ts
create mode 100644 packages/web-shell/client/components/managed/managed-question.test-fixtures.ts
diff --git a/docs/design/2026-11-06-managed-user-questions.md b/docs/design/2026-11-06-managed-user-questions.md
new file mode 100644
index 00000000000..1b59c09a209
--- /dev/null
+++ b/docs/design/2026-11-06-managed-user-questions.md
@@ -0,0 +1,133 @@
+# Managed structured user questions
+
+[English](2026-11-06-managed-user-questions.md) | [简体中文](2026-11-06-managed-user-questions.zh-CN.md)
+
+Status: implemented and locally verified, including MySQL with a forced Harness
+restart and Java service context restart while waiting. Full CI and
+deployed-service acceptance remain pending.
+
+## Problem and current state
+
+Before this change, the daemon offered `ask_user_question`, but ordinary Hosted
+tools did not. The public and WebShell contracts described question Actions as
+planned. The Java projection, response outbox, SDK connector and WebShell accepted
+only permission Actions. Permission waits already persisted an Action and a native
+batch continuation, fence stale writers and resume the same wait after takeover.
+
+The change must make a model question durable before presenting it, preserve
+the answer across delivery failures, and continue the original tool call once.
+Reloading the page must rediscover the question without replaying a prompt.
+
+## Scope
+
+Support root Sessions on ordinary `hosted-workspace-files/1`, `/2`, `/3` and
+`hosted-workspace-shell/1`, including public Plan mode. A question is the only
+tool call in its batch. Reject mixed batches before executing any sibling.
+Initially omit the tool from MCP/Hook Sessions, foreground-only workers,
+resident CSI profiles and child Sessions because their continuation contracts
+need separate qualification. Existing permission behavior remains compatible.
+
+Match the native input vocabulary: one to four questions, each with `question`,
+`header`, two to four options containing `label` and `description`, and optional
+`multiSelect`. Optional `metadata.source` is descriptive only. Questions are
+required and always permit custom text. Single choice accepts either one option
+or custom text; multiple choice permits several options and custom text.
+
+## Design by layer
+
+| Layer | Change |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Hosted tools | Declare and validate the tool on eligible profiles. Persist its assistant batch, input, definition and native continuation before committing a `question` Action. Wait even in `yolo` and `auto` modes. |
+| Durable Action | Preserve permission options versions 1–3. Add question options version 4 with the existing identities, revisions, timestamps, `inputRef`, `continuationRef`, `requiresHumanDecision: true`, `policyRevision: hosted-user-question/1`, and `questions`. Keep the existing opaque Action ID namespace. |
+| Answer | Persist decision version 2 with `answers`, `inputRevision` and `policyRevision`. Each answer has `questionId`, optional `optionIds` and optional `text`. Canonicalize in original question/option order so reordered selections have identical decision bytes. |
+| Java | Validate the question projection and answer against the original options. Reuse actor-scoped idempotency, authorization, reservation, `ACTION_RESPONSE` outbox and durable decision receipts. No schema migration or new queue/table. |
+| SDK | Deliver structured answers through the existing resolve endpoint. Preserve the existing permission overload. |
+| WebShell | Project question Actions, render a form using existing primitives, submit stable IDs and retain the draft during uncertain/failed delivery. Reload fetches pending Actions. |
+
+Generate question IDs `q1` through `q4`, and option IDs `q1o1` through `q4o4`
+from the immutable input order. Labels never act as identity. The public API
+uses snake_case; WebShell and private resources use camelCase. Reuse the
+planned question schemas and add source/tool identity fields where needed.
+
+Bound question prompts to 2048 characters, labels to 256, descriptions to
+1024, and answer text to 8192. Limit serialized tool input to 16 KiB and
+canonical answers to 32 KiB, keeping options, decisions and continuation
+resources within the existing 64 KiB limit. Invalid or oversized model input
+returns a tool error before the wait. Invalid answers leave the Action pending.
+
+## Lifecycle and recovery
+
+Use the existing approval timeout and requested/decided/cancelled/expired
+states. A question cannot be answered by policy changes, an allow rule, a
+classifier or an approval button. The answer must validate IDs, cardinality,
+text eligibility, input revision and policy revision. A repeated equivalent
+answer returns the original result; a conflicting answer cannot replace it.
+
+Reuse the existing native continuation descriptor and original Runtime binding;
+question execution itself creates no Runtime tool execution. On restart, verify
+the saved input/definition and question decision, resume the same Action ID,
+and commit the original call's result before the next model request. A crash
+after decision but before the result must reuse the stored answer. Cancellation
+and expiry settle the existing wait without inventing an answer. Writer lease,
+activation and Workspace generation checks remain in force.
+
+Stateless Action journal validation lives separately from the SQL-backed Action
+Store, preserving the existing class-size ratchet. This separation does not
+change transaction ownership, locks, resource checks or rejection semantics.
+
+The browser computes a bounded idempotency key from canonical structured
+answers and Action/revision identity. It does not store answer text in the key
+or browser storage. It uses the repository's pinned browser SHA-256 library so
+ordinary LAN HTTP works without Web Crypto. An in-flight guard prevents duplicate clicks. Failed or
+unconfirmed delivery keeps the exact draft available for retry; success hides
+the question after the durable response operation confirms completion.
+
+## Affected sources and consumers
+
+- Hosted declaration/execution and approval helpers, native batch continuation,
+ Harness resume and their focused tests.
+- Java Action projection/repository/service/controller, journal reader-version
+ promotion, recovery reference validation and tests.
+- Hosted Java SDK client and connector, with permission compatibility tests.
+- Public OpenAPI and generated WebShell types, provider/hook, question form,
+ Sessions page, translations and tests.
+- Capability documentation and the Managed server README.
+
+## Validation and acceptance
+
+Dry-run the test plan against global `qwen` before implementation. Use a
+deterministic model fixture to request the exact question tool; fixture success
+does not establish real-model or deployed-service acceptance.
+
+Acceptance covers single/multiple/custom answers, duplicate labels, all approval
+modes, Plan, required answers and size limits; refresh before answering; Java
+and Harness restart while waiting and after decision; same-answer retry versus
+conflicting answers; cancellation/expiry; tenant/actor isolation and unchanged
+permission Actions. Focused UI and adapter tests verify request shape and draft
+retention. Real-process and MySQL evidence must be identified separately.
+
+Run root build/typecheck/bundle and affected TypeScript tests. Follow the Java
+module's shared ten-minute local verification budget, including prerequisites;
+leave full Maven, MySQL and Hosted/fault gates to CI where local evidence is
+unavailable. Read the full diff until two consecutive clean self-audits, then
+perform independent review. Report unrun checks explicitly.
+
+Local verification passed root build/typecheck/bundle, focused UI, SDK and Java
+tests, and the affected permission/native-continuation regressions. The selected
+MySQL 8.4 test force-kills the packaged Harness, restarts the Java Spring context
+in the same test JVM and starts a replacement Harness, reloads
+the original pending question, answers it and observes exactly two model requests
+and no Runtime tool execution. It uses a deterministic model fixture. Recovery
+after a persisted decision is covered by the local journal takeover tests;
+the corresponding real-process fault case, Java process crash, full profiles,
+SpotBugs, complete browser acceptance and real-model/deployed-service acceptance
+remain pending. A Chromium probe verifies that ordinary HTTP and localhost
+produce the same answer key, including Unicode and reordered selections.
+
+## Risks and open questions
+
+The main risks are cross-language decision canonicalization, accidentally
+auto-answering questions, and recovering after the decision/result boundary.
+Tests must exercise these boundaries rather than only schema acceptance.
+There are no unresolved product decisions for this scope. Broader profile and
+mixed-batch support are follow-up work, not silently advertised capability.
diff --git a/docs/design/2026-11-06-managed-user-questions.zh-CN.md b/docs/design/2026-11-06-managed-user-questions.zh-CN.md
new file mode 100644
index 00000000000..a086e054f72
--- /dev/null
+++ b/docs/design/2026-11-06-managed-user-questions.zh-CN.md
@@ -0,0 +1,108 @@
+# Managed 结构化用户问答
+
+[English](2026-11-06-managed-user-questions.md) | [简体中文](2026-11-06-managed-user-questions.zh-CN.md)
+
+状态:已实现并完成本地验证,包括 MySQL 上等待回答期间强制终止并重启
+Harness、重启 Java 服务上下文。完整 CI 和已部署服务验收仍待完成。
+
+## 问题与当前状态
+
+本次变更前,daemon 提供 `ask_user_question`,普通 Hosted 工具尚未提供。
+公开 API 和 WebShell 契约中的问题 Action 标为 planned。Java 投影、回答 outbox、
+SDK 连接器和 WebShell 只接受审批 Action。现有审批等待已经能够持久化 Action
+和原生批次续接描述、隔离过期写入者,并在接管后恢复同一个等待。
+
+本次变更需要在展示前持久化问题,在投递失败时保留回答,并让原始工具调用仅
+继续一次。刷新页面必须能够重新发现问题,无需重新发送 prompt。
+
+## 范围
+
+支持普通 `hosted-workspace-files/1`、`/2`、`/3` 和
+`hosted-workspace-shell/1` 的根 Session,包括公开 Plan 模式。问题必须是批次中
+唯一的工具调用;混合批次在执行任何相邻调用之前整体拒绝。首期不向 MCP/Hook
+Session、仅前台 worker、resident CSI profile 和子 Session 声明该工具,因为
+这些组合的续接契约需要单独验收。保持现有审批行为兼容。
+
+匹配原生输入字段:一至四个问题,每个包含 `question`、`header`、二至四个
+包含 `label` 和 `description` 的选项,以及可选 `multiSelect`。
+可选 `metadata.source` 仅作描述。所有问题必答且允许自定义文本。单选接受一个
+选项或自定义文本;多选允许多个选项与自定义文本同时提交。
+
+## 分层设计
+
+| 层次 | 变更 |
+| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Hosted 工具 | 在适用 profile 声明并校验工具。提交 `question` Action 前持久化助手批次、输入、声明和原生续接描述。在 `yolo`、`auto` 模式也等待回答。 |
+| 持久化 Action | 保持审批 options 版本 1–3。增加问题 options 版本 4,包含现有身份、版本、时间字段、`inputRef`、`continuationRef`、`requiresHumanDecision: true`、`policyRevision: hosted-user-question/1` 和 `questions`。沿用现有不透明 Action ID 命名空间。 |
+| 回答 | 保存版本 2 的 decision,包含 `answers`、`inputRevision`、`policyRevision`。每个回答包含 `questionId`、可选 `optionIds` 和可选 `text`。按原问题及选项顺序规范化,使重排的选择产生相同 decision 字节。 |
+| Java | 对照原始 options 校验问题投影和回答。复用 actor 作用域幂等性、鉴权、预约、`ACTION_RESPONSE` outbox 和持久化 decision receipt。不新增数据库迁移、队列或表。 |
+| SDK | 通过现有 resolve 端点投递结构化回答。保留现有审批重载。 |
+| WebShell | 投影问题 Action,使用现有基础组件渲染表单,提交稳定 ID,在投递不确定或失败时保留草稿。刷新后读取待回答 Action。 |
+
+按不可变输入顺序生成问题 ID `q1` 至 `q4`,选项 ID `q1o1` 至 `q4o4`。
+标签不作为身份。公开 API 使用 snake_case;WebShell 和私有资源使用 camelCase。
+复用已规划的问题 schema,并在需要时补充来源和工具身份字段。
+
+问题文本最多 2048 字符,选项标签 256 字符,描述 1024 字符,回答文本 8192
+字符。序列化工具输入最多 16 KiB,规范化回答最多 32 KiB,确保 options、decision
+和续接资源不超过现有 64 KiB 上限。无效或过大的模型输入在等待前返回工具错误;
+无效回答保持 Action 待回答。
+
+## 生命周期与恢复
+
+复用现有审批超时及 requested/decided/cancelled/expired 状态。策略变更、allow
+规则、分类器或审批按钮都不能回答问题。回答必须校验 ID、选择数量、文本资格、
+输入版本和策略版本。重复的等价回答返回原结果;冲突回答不能替换原回答。
+
+复用现有原生续接描述及原 Runtime 绑定;问题本身不创建 Runtime 工具执行。
+重启后校验保存的输入、声明和问题 decision,恢复同一个 Action ID,在下一次
+模型请求前提交原调用的结果。decision 落盘后、结果提交前崩溃时必须复用已保存
+的回答。取消和过期结束原等待,不编造回答。保留写入租约、activation 和
+Workspace generation 检查。
+
+无状态 Action journal 校验与基于 SQL 的 Action Store 分开,遵守现有类大小
+门槛;这一拆分不改变事务归属、锁、资源校验或拒绝语义。
+
+浏览器根据规范化的结构化回答及 Action/版本身份计算长度受限的幂等键,不在键
+或浏览器存储中保存回答文本。采用仓库固定版本的浏览器 SHA-256 库,确保普通
+局域网 HTTP 无需 Web Crypto 也能回答。同步的提交中状态阻止重复点击。投递失败或不确定
+时保留原草稿用于重试;持久化回答操作确认完成后才隐藏问题。
+
+## 受影响源码与消费者
+
+- Hosted 声明/执行、审批辅助逻辑、原生批次续接、Harness 恢复及对应测试。
+- Java Action 投影/仓储/服务/控制器、journal reader-version 提升、恢复引用校验及测试。
+- Hosted Java SDK 客户端与连接器,以及审批兼容性测试。
+- 公开 OpenAPI 和生成的 WebShell 类型、provider/hook、问题表单、Sessions 页面、翻译及测试。
+- 能力文档和 Managed server README。
+
+## 验证与验收
+
+实现前使用全局 `qwen` dry-run 测试计划。使用确定性模型 fixture 请求指定问题
+工具;fixture 通过不代表真实模型或已部署服务验收通过。
+
+验收覆盖单选、多选、自定义回答、重复标签、所有审批模式、Plan、必答与大小
+限制;回答前刷新;等待中及 decision 后 Java/Harness 重启;相同回答重试与
+冲突回答;取消/过期;tenant/actor 隔离;现有审批 Action 不回归。聚焦 UI 和
+适配器测试验证请求格式和草稿保留。真实进程与 MySQL 证据分别列出。
+
+执行根目录 build/typecheck/bundle 和受影响 TypeScript 测试。遵守 Java 模块
+包含前置准备的共享十分钟本地验证预算;本地证据不可得时,将完整 Maven、MySQL
+和 Hosted/fault 检查交由 CI。完整阅读 diff,达到连续两次干净自审,再执行独立
+评审。明确报告未执行的检查。
+
+本地已通过根目录 build/typecheck/bundle、聚焦 UI/SDK/Java 测试及受影响的
+审批/原生续接回归。选定的 MySQL 8.4 测试强制终止已打包 Harness,在同一测试
+JVM 中重启 Java Spring 上下文并启动替代 Harness,重新读取原待回答问题并提交
+回答,观察到恰好两次模型请求且没有
+Runtime 工具执行。该测试使用确定性模型 fixture。decision 已持久化后的恢复
+由本地 journal 接管测试覆盖;对应的真实进程故障用例、Java 进程崩溃、完整
+profiles、SpotBugs、完整浏览器验收以及真实模型/已部署服务验收仍待完成。
+Chromium 探针已验证普通 HTTP 和 localhost 产生相同回答键,样本包含 Unicode
+和重排的选择。
+
+## 风险与开放问题
+
+主要风险是跨语言 decision 规范化、错误自动回答,以及 decision/结果边界的
+恢复。测试必须覆盖这些边界,不能只验证 schema 接受。当前范围没有未解决的
+产品决策。更广的 profile 和混合批次支持属于后续工作,不隐式声明支持。
diff --git a/packages/cli/src/serve/hosted-approval-continuation.ts b/packages/cli/src/serve/hosted-approval-continuation.ts
index 29cb8cdde20..7f0b54a3b90 100644
--- a/packages/cli/src/serve/hosted-approval-continuation.ts
+++ b/packages/cli/src/serve/hosted-approval-continuation.ts
@@ -28,6 +28,10 @@ import {
hostedActionDenied,
readHostedActionOptions,
} from './hosted-tool-approval.js';
+import {
+ hostedQuestions,
+ readHostedQuestionDecision,
+} from './hosted-user-question.js';
import { hostedRuntimeSessionId } from './hosted-workspace-broker.js';
import { readHostedTurnApprovalSelection } from './hosted-approval-selection.js';
@@ -39,6 +43,7 @@ const NATIVE_TOOLS = new Set([
'glob',
'todo_write',
'web_fetch',
+ 'ask_user_question',
]);
export interface HostedNativePlannedCall {
@@ -232,6 +237,13 @@ export async function readHostedApprovalContinuation(
plan.calls[0].call?.name === 'exit_plan_mode' &&
(await readHostedTurnApprovalSelection(session, promptId))?.planMode ===
true;
+ const questionBatch =
+ plan.calls.length === 1 && plan.calls[0].call?.name === 'ask_user_question';
+ if (
+ !questionBatch &&
+ plan.calls.some((item) => item.call?.name === 'ask_user_question')
+ )
+ throw new Error('Saved question cannot share a tool batch.');
for (const [ordinal, item] of plan.calls.entries()) {
if (
Object.keys(item).length !==
@@ -324,6 +336,12 @@ export async function readHostedApprovalContinuation(
!isDeepStrictEqual(item.call.args, payload.input))
)
throw new Error('Saved public plan is invalid.');
+ if (
+ questionBatch &&
+ (!isDeepStrictEqual(item.call.args, payload.input) ||
+ (item.actionId === null && item.refusal === null))
+ )
+ throw new Error('Saved question input or Action is missing.');
const definition = JSON.parse(
(await session.resources.read(item.definitionRef)).toString('utf8'),
) as { name?: string };
@@ -333,8 +351,18 @@ export async function readHostedApprovalContinuation(
const action = session.authority.action(item.actionId);
if (!action) throw new Error('Original approval is missing.');
const options = await readHostedActionOptions(session, action);
+ if (options.v === 4) {
+ if (
+ !questionBatch ||
+ !isDeepStrictEqual(hostedQuestions(payload.input), options.questions)
+ )
+ throw new Error('Saved question does not match its original input.');
+ if (action.state === 'decided')
+ await readHostedQuestionDecision(session, action, options);
+ }
if (
- options.v !== 3 ||
+ (options.v !== 3 && options.v !== 4) ||
+ (questionBatch && options.v !== 4) ||
options.functionCallId !== item.call.callId ||
options.turnId !== promptId ||
options.toolName !==
@@ -351,6 +379,7 @@ export async function readHostedApprovalContinuation(
: item.inputRef,
)) ||
(action.state === 'decided' &&
+ options.v !== 4 &&
!hostedActionAllowed(action, options.policyRevision) &&
!hostedActionDenied(action, options.policyRevision)) ||
(plan.stage === 'approval' &&
@@ -455,7 +484,7 @@ export async function findHostedApprovalContinuation(
!callIds.includes(options.functionCallId)
)
continue;
- if (options.v !== 3) return undefined;
+ if (options.v !== 3 && options.v !== 4) return undefined;
approvalRef ??= options.continuationRef;
}
const planRef = batch
diff --git a/packages/cli/src/serve/hosted-approval-fallback.test.ts b/packages/cli/src/serve/hosted-approval-fallback.test.ts
index 9cfd0fa5504..697a0bda791 100644
--- a/packages/cli/src/serve/hosted-approval-fallback.test.ts
+++ b/packages/cli/src/serve/hosted-approval-fallback.test.ts
@@ -336,6 +336,7 @@ function approvalSelectionFixture(
'first',
{
requestId: 'first',
+ kind: 'permission',
inputRevision: 1,
optionsRef: optionsRef('first', 'call-first', 3),
},
@@ -344,6 +345,7 @@ function approvalSelectionFixture(
'legacy',
{
requestId: 'legacy',
+ kind: 'permission',
inputRevision: 1,
optionsRef: optionsRef('legacy', 'call-legacy', legacyVersion),
},
diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts
index 2b33ef96a28..4cd6ddbd7c0 100644
--- a/packages/cli/src/serve/hosted-harness-session.test.ts
+++ b/packages/cli/src/serve/hosted-harness-session.test.ts
@@ -16642,6 +16642,7 @@ describe('Hosted Harness no-tool session', () => {
'edit',
'web_fetch',
'glob',
+ 'ask_user_question',
]);
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
'X-Qwen-Client-Id',
@@ -16692,6 +16693,7 @@ describe('Hosted Harness no-tool session', () => {
'edit',
'web_fetch',
'glob',
+ 'ask_user_question',
]);
expect(state.model).toHaveBeenCalledTimes(2);
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
@@ -16787,6 +16789,7 @@ describe('Hosted Harness no-tool session', () => {
'send_message',
// #13803: and, behind its own kind gate, its Workflow tool.
'workflow',
+ 'ask_user_question',
]);
expect(acquire).not.toHaveBeenCalled();
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
@@ -16853,6 +16856,7 @@ describe('Hosted Harness no-tool session', () => {
'send_message',
// #13803: and, behind its own kind gate, its Workflow tool.
'workflow',
+ 'ask_user_question',
]);
expect(state.model).toHaveBeenCalledTimes(2);
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
@@ -30093,6 +30097,7 @@ describe('Hosted Harness Runtime turn takeover', () => {
'edit',
'web_fetch',
...(toolProfile.endsWith('/2') ? ['glob'] : []),
+ 'ask_user_question',
]);
// The takeover-built attachment starts with an undefined slot: the
// recovered turn must have populated it before driving the model, or
diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts
index a8e3f252b2c..f7ab5cfe714 100644
--- a/packages/cli/src/serve/hosted-harness-session.ts
+++ b/packages/cli/src/serve/hosted-harness-session.ts
@@ -75,6 +75,7 @@ import {
parseHostedActionOptions,
hostedLiveApprovalPoll,
} from './hosted-tool-approval.js';
+import { readHostedQuestionDecision } from './hosted-user-question.js';
import {
HOSTED_RECOVERY_RESOURCE_KINDS,
@@ -3631,8 +3632,11 @@ async function executeHostedTurn(
);
const action = authority.action(actionId)!;
await harness.resolveDurableWait();
+ if (action.state === 'decided' && options.v === 4)
+ await readHostedQuestionDecision(session.managed, action, options);
if (
action.state === 'decided' &&
+ options.v !== 4 &&
!hostedActionAllowed(action, options.policyRevision) &&
!hostedActionDenied(action, options.policyRevision)
)
@@ -3643,9 +3647,11 @@ async function executeHostedTurn(
index === plan.approvalOrdinal
? {
...item,
- refusal: hostedActionAllowed(action, options.policyRevision)
- ? null
- : 'Original approval denied the call.',
+ refusal:
+ (options.v === 4 && action.state === 'decided') ||
+ hostedActionAllowed(action, options.policyRevision)
+ ? null
+ : 'Original approval denied the call.',
}
: item,
);
@@ -3863,6 +3869,7 @@ async function executeHostedTurn(
monitors: session.monitors,
backgroundLane: session.backgroundLane,
childAgents: childAgentsOf(session),
+ childDepth: session.childDepth,
messages: session.messages,
teams: session.teams,
commands: session.commands,
@@ -10991,6 +10998,7 @@ export function registerHostedHarnessSessionRoutes(
monitors: session.monitors,
backgroundLane: session.backgroundLane,
childAgents: childAgentsOf(session),
+ childDepth: session.childDepth,
messages: session.messages,
teams: session.teams,
commands: session.commands,
diff --git a/packages/cli/src/serve/hosted-plan-mode.ts b/packages/cli/src/serve/hosted-plan-mode.ts
index 79e5282798c..a2a64c83bb3 100644
--- a/packages/cli/src/serve/hosted-plan-mode.ts
+++ b/packages/cli/src/serve/hosted-plan-mode.ts
@@ -25,9 +25,14 @@ export const HOSTED_PUBLIC_PLAN_TOOL: FunctionDeclaration = {
},
};
-export const HOSTED_PUBLIC_PLAN_READS = new Set(['read_file', 'glob', 'skill']);
+export const HOSTED_PUBLIC_PLAN_READS = new Set([
+ 'read_file',
+ 'glob',
+ 'skill',
+ 'ask_user_question',
+]);
export const HOSTED_PUBLIC_PLAN_REFUSAL =
- 'Plan mode is read-only: only read_file, glob, static skill loading and a single exit_plan_mode call are allowed. Submit a plan with exit_plan_mode and wait for the user to approve it before making changes or running Shell commands.';
+ 'Plan mode is read-only: only read_file, glob, static skill loading, a single ask_user_question call and a single exit_plan_mode call are allowed. Submit a plan with exit_plan_mode and wait for the user to approve it before making changes or running Shell commands.';
export async function hostedPublicPlanActive(
session: ManagedSession,
diff --git a/packages/cli/src/serve/hosted-runtime-recovery.test.ts b/packages/cli/src/serve/hosted-runtime-recovery.test.ts
index 1e763829d28..bbe171e76b2 100644
--- a/packages/cli/src/serve/hosted-runtime-recovery.test.ts
+++ b/packages/cli/src/serve/hosted-runtime-recovery.test.ts
@@ -661,7 +661,7 @@ describe('recoverHostedRuntimeTurn', () => {
await harness.commitDurableWait(
{
requestId: ACTION_ID,
- kind: 'execute',
+ kind: 'permission',
source: 'tool_call',
optionsRef: await session.resources.publish(
'managed-action-options',
diff --git a/packages/cli/src/serve/hosted-tool-approval.ts b/packages/cli/src/serve/hosted-tool-approval.ts
index 44f416c2bc4..892a083851a 100644
--- a/packages/cli/src/serve/hosted-tool-approval.ts
+++ b/packages/cli/src/serve/hosted-tool-approval.ts
@@ -19,6 +19,15 @@ import {
ManagedSessionConflictError,
type ManagedSessionAction,
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js';
+import {
+ assertHostedQuestions,
+ HOSTED_QUESTION_POLICY,
+ hostedQuestionDecisionBytes,
+ normalizeHostedQuestionAnswers,
+ HostedQuestionInputError,
+ type HostedActionQuestion,
+ type HostedQuestionAnswer,
+} from './hosted-user-question.js';
export const HOSTED_TOOL_APPROVAL_POLICY = 'hosted-tool-approval/1';
export const HOSTED_APPROVAL_TIMEOUT_MS = 10 * 60_000;
@@ -166,19 +175,29 @@ interface HostedActionOptionsBase {
readonly createdAt: number;
readonly expiresAt: number;
readonly requiresHumanDecision?: true;
- readonly options: ReadonlyArray<{
- readonly id: string;
- readonly label: string;
- }>;
}
export type HostedActionOptions = HostedActionOptionsBase &
(
- | { readonly v: 1 }
- | { readonly v: 2; readonly inputRef: ManagedSessionDurableRef }
+ | ({
+ readonly options: ReadonlyArray<{
+ readonly id: string;
+ readonly label: string;
+ }>;
+ } & (
+ | { readonly v: 1 }
+ | { readonly v: 2; readonly inputRef: ManagedSessionDurableRef }
+ | {
+ readonly v: 3;
+ readonly inputRef?: ManagedSessionDurableRef;
+ readonly continuationRef: ManagedSessionDurableRef;
+ }
+ ))
| {
- readonly v: 3;
- readonly inputRef?: ManagedSessionDurableRef;
+ readonly v: 4;
+ readonly questions: readonly HostedActionQuestion[];
+ readonly requiresHumanDecision: true;
+ readonly inputRef: ManagedSessionDurableRef;
readonly continuationRef: ManagedSessionDurableRef;
}
);
@@ -191,6 +210,8 @@ export function parseHostedActionOptions(bytes: Buffer): HostedActionOptions {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Invalid Hosted Action options.');
const options = value as Record;
+ const question = options['v'] === 4;
+ if (question) assertHostedQuestions(options['questions']);
const fields = [
'v',
'requestId',
@@ -201,46 +222,55 @@ export function parseHostedActionOptions(bytes: Buffer): HostedActionOptions {
'inputRevision',
'createdAt',
'expiresAt',
- 'options',
+ question ? 'questions' : 'options',
];
if (options['requiresHumanDecision'] !== undefined) {
if (options['requiresHumanDecision'] !== true)
throw new Error('Invalid Hosted Action human decision floor.');
fields.push('requiresHumanDecision');
}
- if (options['v'] === 2 || (options['v'] === 3 && options['inputRef']))
- fields.push('inputRef');
- if (options['v'] === 3) fields.push('continuationRef');
if (
- ![1, 2, 3].includes(options['v'] as number) ||
+ question ||
+ options['v'] === 2 ||
+ (options['v'] === 3 && options['inputRef'])
+ )
+ fields.push('inputRef');
+ if (question || options['v'] === 3) fields.push('continuationRef');
+ if (
+ ![1, 2, 3, 4].includes(options['v'] as number) ||
Object.keys(options).length !== fields.length ||
fields.some((field) => !Object.hasOwn(options, field)) ||
!Number.isSafeInteger(options['inputRevision']) ||
(options['inputRevision'] as number) < 1 ||
- options['policyRevision'] !== HOSTED_TOOL_APPROVAL_POLICY ||
+ options['policyRevision'] !==
+ (question ? HOSTED_QUESTION_POLICY : HOSTED_TOOL_APPROVAL_POLICY) ||
+ (question &&
+ (options['requiresHumanDecision'] !== true ||
+ options['toolName'] !== 'ask_user_question')) ||
!Number.isSafeInteger(options['createdAt']) ||
!Number.isSafeInteger(options['expiresAt']) ||
(options['createdAt'] as number) < 0 ||
(options['expiresAt'] as number) <= (options['createdAt'] as number) ||
- !Array.isArray(options['options']) ||
- options['options'].length !== 2 ||
- !isDeepStrictEqual(
- options['options'].map((option) =>
- option && typeof option === 'object' && !Array.isArray(option)
- ? option['id']
- : null,
- ),
- ['allow', 'deny'],
- ) ||
- options['options'].some(
- (option) =>
- !option ||
- typeof option !== 'object' ||
- Array.isArray(option) ||
- Object.keys(option).length !== 2 ||
- typeof option['label'] !== 'string' ||
- !option['label'],
- )
+ (!question &&
+ (!Array.isArray(options['options']) ||
+ options['options'].length !== 2 ||
+ !isDeepStrictEqual(
+ options['options'].map((option) =>
+ option && typeof option === 'object' && !Array.isArray(option)
+ ? option['id']
+ : null,
+ ),
+ ['allow', 'deny'],
+ ) ||
+ options['options'].some(
+ (option) =>
+ !option ||
+ typeof option !== 'object' ||
+ Array.isArray(option) ||
+ Object.keys(option).length !== 2 ||
+ typeof option['label'] !== 'string' ||
+ !option['label'],
+ )))
)
throw new Error('Invalid Hosted Action options.');
for (const field of ['requestId', 'turnId', 'functionCallId', 'toolName'])
@@ -407,7 +437,8 @@ export async function readHostedActionOptions(
);
if (
options.requestId !== action.requestId ||
- options.inputRevision !== action.inputRevision
+ options.inputRevision !== action.inputRevision ||
+ action.kind !== (options.v === 4 ? 'question' : 'permission')
)
throw new Error('Hosted Action options do not match the Action.');
return options;
@@ -442,7 +473,8 @@ export type HostedActionResolution =
readonly body: {
readonly requestId: string;
readonly state: 'decided';
- readonly optionId: string;
+ readonly optionId?: string;
+ readonly answers?: readonly HostedQuestionAnswer[];
};
}
| {
@@ -489,22 +521,46 @@ export async function resolveHostedAction(
if (
request === undefined ||
Object.keys(request).length !== 3 ||
- typeof optionId !== 'string' ||
- !options.options.some((option) => option.id === optionId) ||
+ (options.v === 4
+ ? !Object.hasOwn(request, 'answers')
+ : typeof optionId !== 'string' ||
+ !options.options.some((option) => option.id === optionId)) ||
request['inputRevision'] !== existing.inputRevision ||
request['policyRevision'] !== options.policyRevision
)
return { status: 400, code: 'invalid_action_response' };
const writable = () => !isBlocked() && !authority.writesStopped;
- const bytes = decisionBytes(
- optionId,
- existing.inputRevision,
- options.policyRevision,
- );
+ let answers: HostedQuestionAnswer[] | undefined;
+ try {
+ if (options.v === 4)
+ answers = normalizeHostedQuestionAnswers(
+ options.questions,
+ request['answers'],
+ );
+ } catch (cause) {
+ if (!(cause instanceof HostedQuestionInputError)) throw cause;
+ return { status: 400, code: 'invalid_action_response' };
+ }
+ const bytes = answers
+ ? hostedQuestionDecisionBytes(
+ answers,
+ existing.inputRevision,
+ options.policyRevision,
+ )
+ : decisionBytes(
+ optionId as string,
+ existing.inputRevision,
+ options.policyRevision,
+ );
+ const response = {
+ requestId,
+ state: 'decided' as const,
+ ...(answers ? { answers } : { optionId: optionId as string }),
+ };
const digest = createHash('sha256').update(bytes).digest('hex');
const decided = (action: ManagedSessionAction): HostedActionResolution =>
action.decisionRef?.digest === digest
- ? { status: 200, body: { requestId, state: 'decided', optionId } }
+ ? { status: 200, body: response }
: { status: 409, code: 'action_already_resolved' };
// What is already recorded is answered as it stands, blocked or not.
const recorded = (): HostedActionResolution | undefined => {
@@ -568,7 +624,7 @@ export async function resolveHostedAction(
return failed(cause);
}
waiters.notify(requestId);
- return { status: 200, body: { requestId, state: 'decided', optionId } };
+ return { status: 200, body: response };
}
export function hostedLiveApprovalPoll(
diff --git a/packages/cli/src/serve/hosted-user-question.test.ts b/packages/cli/src/serve/hosted-user-question.test.ts
new file mode 100644
index 00000000000..15bbf72eba3
--- /dev/null
+++ b/packages/cli/src/serve/hosted-user-question.test.ts
@@ -0,0 +1,562 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createHash, randomUUID } from 'node:crypto';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ openManagedSession,
+ type ManagedSession,
+} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
+import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
+import { createManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js';
+import {
+ HOSTED_QUESTION_POLICY,
+ hostedQuestions,
+ assertHostedQuestions,
+ normalizeHostedQuestionAnswers,
+ hostedQuestionDecisionBytes,
+} from './hosted-user-question.js';
+import { HostedWorkspaceToolTurn } from './hosted-workspace-tool-turn.js';
+import { HostedWorkspaceBroker } from './hosted-workspace-broker.js';
+import { WORKSPACE_CAPABILITY_DIGEST } from './managed-workspace-activation.js';
+import {
+ HostedApprovalWaiters,
+ resolveHostedAction,
+ readHostedActionOptions,
+ hostedLiveApprovalPoll,
+ endHostedAction,
+ type HostedApprovalMode,
+} from './hosted-tool-approval.js';
+import { findHostedApprovalContinuation } from './hosted-approval-continuation.js';
+import { hostedPublicPlanActive } from './hosted-plan-mode.js';
+
+const input = {
+ questions: [
+ {
+ question: 'Which features should be enabled?',
+ header: 'Features',
+ multiSelect: true,
+ options: [
+ { label: 'Read, write', description: 'First option' },
+ {
+ label: 'Read, write',
+ description: 'Second option with the same label',
+ },
+ ],
+ },
+ ],
+};
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('Hosted question protocol', () => {
+ it('preserves option identity and canonicalizes order without changing text', () => {
+ const questions = hostedQuestions(input);
+ assertHostedQuestions(questions);
+ const answer = normalizeHostedQuestionAnswers(questions, [
+ {
+ questionId: 'q1',
+ optionIds: ['q1o2', 'q1o1'],
+ text: '中文 / "quote"\n\u000b',
+ },
+ ]);
+ expect(answer).toEqual([
+ {
+ questionId: 'q1',
+ optionIds: ['q1o1', 'q1o2'],
+ text: '中文 / "quote"\n\u000b',
+ },
+ ]);
+ expect(
+ hostedQuestionDecisionBytes(answer, 1, HOSTED_QUESTION_POLICY).toString(),
+ ).toBe(
+ '{"v":2,"answers":[{"questionId":"q1","optionIds":["q1o1","q1o2"],"text":"中文 / \\"quote\\"\\n\\u000b"}],"inputRevision":1,"policyRevision":"hosted-user-question/1"}',
+ );
+ });
+
+ it.each(
+ [
+ [],
+ [{ questionId: 'unknown', optionIds: ['q1o1'] }],
+ [{ questionId: 'q1' }],
+ [{ questionId: 'q1', optionIds: [] }],
+ [{ questionId: 'q1', optionIds: ['q1o1', 'q1o1'] }],
+ [{ questionId: 'q1', optionIds: ['unknown'] }],
+ [{ questionId: 'q1', text: ' ' }],
+ [{ questionId: 'q1', text: '\ud800' }],
+ [{ questionId: 'q1', text: 'x'.repeat(8193) }],
+ [{ questionId: 'q1', optionIds: ['q1o1'], extra: true }],
+ ].map((answer, index) => ({ answer, index })),
+ )(
+ 'rejects invalid answer $index without producing a decision',
+ ({ answer }) => {
+ expect(() =>
+ normalizeHostedQuestionAnswers(hostedQuestions(input), answer),
+ ).toThrow();
+ },
+ );
+
+ it('requires a single choice or custom text and bounds the aggregate bytes', () => {
+ const single = hostedQuestions({
+ questions: [{ ...input.questions[0], multiSelect: false }],
+ });
+ expect(() =>
+ normalizeHostedQuestionAnswers(single, [
+ { questionId: 'q1', optionIds: ['q1o1'], text: 'both' },
+ ]),
+ ).toThrow();
+ expect(
+ normalizeHostedQuestionAnswers(single, [
+ { questionId: 'q1', text: 'another approach' },
+ ]),
+ ).toEqual([{ questionId: 'q1', text: 'another approach' }]);
+ const four = hostedQuestions({
+ questions: Array.from({ length: 4 }, () => input.questions[0]),
+ });
+ expect(() =>
+ normalizeHostedQuestionAnswers(
+ four,
+ four.map((q) => ({ questionId: q.id, text: '文'.repeat(8192) })),
+ ),
+ ).toThrow('32 KiB');
+ expect(() =>
+ hostedQuestions({ ...input, metadata: { source: 'x'.repeat(16384) } }),
+ ).toThrow('16 KiB');
+ expect(() =>
+ hostedQuestions({
+ questions: [{ ...input.questions[0], multiSelect: 'true' }],
+ }),
+ ).toThrow();
+ expect(() =>
+ hostedQuestions({
+ questions: [{ ...input.questions[0], question: '\ud800' }],
+ }),
+ ).toThrow();
+ expect(() =>
+ hostedQuestions({ questions: [{ ...input.questions[0], extra: true }] }),
+ ).toThrow();
+ });
+});
+
+const scenarios: Array<{
+ mode: HostedApprovalMode;
+ profile: string;
+ restart: string;
+ planMode: boolean;
+ terminal?: 'cancelled' | 'expired';
+ mixed?: boolean;
+}> = [
+ ...(['yolo', 'auto', 'default', 'auto-edit'] as const).map((mode) => ({
+ mode,
+ profile: 'hosted-workspace-files/1',
+ restart: 'none',
+ planMode: false,
+ })),
+ ...['hosted-workspace-files/1', 'hosted-workspace-shell/1'].flatMap(
+ (profile) =>
+ ['waiting', 'decided'].map((restart) => ({
+ mode: 'yolo' as const,
+ profile,
+ restart,
+ planMode: false,
+ })),
+ ),
+ {
+ mode: 'yolo' as const,
+ profile: 'hosted-workspace-shell/1',
+ restart: 'waiting',
+ planMode: true,
+ },
+ ...['hosted-workspace-files/2', 'hosted-workspace-files/3'].map(
+ (profile) => ({
+ mode: 'yolo' as const,
+ profile,
+ restart: 'none',
+ planMode: false,
+ }),
+ ),
+ ...(['cancelled', 'expired'] as const).map((terminal) => ({
+ mode: 'yolo' as const,
+ profile: 'hosted-workspace-files/1',
+ restart: 'none',
+ planMode: false,
+ terminal,
+ })),
+ {
+ mode: 'yolo',
+ profile: 'hosted-workspace-files/1',
+ restart: 'none',
+ planMode: false,
+ mixed: true,
+ },
+];
+
+it.each(scenarios)(
+ 'handles the original question ($mode, $profile, restart=$restart, plan=$planMode, terminal=$terminal, mixed=$mixed)',
+ async ({ mode, profile, restart, planMode, terminal, mixed }) => {
+ const root = await mkdtemp(path.join(tmpdir(), 'managed-user-questions-'));
+ const sessionKey = {
+ tenantId: 'tenant',
+ workspaceId: 'workspace',
+ sessionId: randomUUID(),
+ };
+ const promptId = randomUUID();
+ const sessionOptions = {
+ runtimeBaseDir: root,
+ cwd: root,
+ transcriptPath: path.join(root, 'transcript.jsonl'),
+ sessionId: sessionKey.sessionId,
+ sessionKey,
+ version: 'question-test',
+ activationLeaseDurationMs: 60000,
+ };
+ const resources = LocalManagedSessionResourceStore.create({
+ runtimeBaseDir: root,
+ sessionKey,
+ });
+ let session = await openManagedSession({
+ ...sessionOptions,
+ workerId: 'original',
+ create: {
+ definitionRef: await resources.publish(
+ 'managed-definition',
+ Buffer.from(
+ JSON.stringify({ engine: 'managed', toolProfile: profile }),
+ ),
+ ),
+ rootSnapshotRef: await resources.publish(
+ 'managed-root',
+ Buffer.from('{}'),
+ ),
+ createdBy: 'question-test',
+ },
+ });
+ const controller = new AbortController();
+ let running: Promise | undefined;
+ try {
+ const content = Buffer.from(
+ JSON.stringify([{ type: 'text', text: 'Ask before proceeding' }]),
+ );
+ await session.authority.submitInput(
+ {
+ operation: 'submitInput',
+ commandId: promptId,
+ sessionKey,
+ contentDigest: createHash('sha256').update(content).digest('hex'),
+ },
+ {
+ inputId: promptId,
+ turnId: promptId,
+ source: 'hosted-harness',
+ contentRef: await resources.publish('managed-input', content),
+ admissionRef: await resources.publish(
+ 'managed-admission',
+ Buffer.from(
+ JSON.stringify({
+ promptId,
+ approvalSelection: {
+ mode,
+ revision: 1,
+ ...(planMode ? { planMode: true } : {}),
+ },
+ }),
+ ),
+ ),
+ deadline: null,
+ wakeReason: 'input',
+ },
+ );
+ vi.spyOn(
+ HostedWorkspaceBroker.prototype,
+ 'resolveCapabilities',
+ ).mockResolvedValue();
+ vi.spyOn(
+ HostedWorkspaceBroker.prototype,
+ 'capabilityDigest',
+ 'get',
+ ).mockReturnValue(WORKSPACE_CAPABILITY_DIGEST);
+ vi.spyOn(HostedWorkspaceBroker.prototype, 'warm').mockImplementation(
+ async function (this: HostedWorkspaceBroker) {
+ this.runtime = {
+ bindingId: 'original-binding',
+ generation: '1',
+ workspaceGeneration: '1',
+ };
+ },
+ );
+ vi.spyOn(HostedWorkspaceBroker.prototype, 'acquire').mockResolvedValue();
+ vi.spyOn(
+ HostedWorkspaceBroker.prototype,
+ 'fileHistory',
+ ).mockResolvedValue({
+ ownerSessionId: sessionKey.sessionId,
+ snapshots: [],
+ files: {},
+ });
+ const prepare = vi.spyOn(HostedWorkspaceBroker.prototype, 'prepare');
+ const execute = vi.spyOn(HostedWorkspaceBroker.prototype, 'execute');
+ const waiters = new HostedApprovalWaiters();
+ if (restart !== 'none')
+ vi.spyOn(waiters, 'wait').mockImplementation(
+ async (_id, _expiry, signal) => {
+ await new Promise((resolve) =>
+ signal.addEventListener('abort', () => resolve(), { once: true }),
+ );
+ },
+ );
+ const makeTurn = (
+ owner: ManagedSession,
+ ownerWaiters: HostedApprovalWaiters,
+ extra?: { childDepth?: number; foregroundOnly?: boolean },
+ ) =>
+ new HostedWorkspaceToolTurn(
+ { baseUrl: 'http://127.0.0.1:1', token: 'test-no-network' },
+ owner,
+ createManagedHarnessHandle(owner),
+ promptId,
+ async (type, parts, model, identity) => {
+ const uuid = identity?.uuid ?? randomUUID();
+ await owner.sink.write({
+ uuid,
+ parentUuid: null,
+ sessionId: sessionKey.sessionId,
+ timestamp: new Date().toISOString(),
+ type,
+ model,
+ cwd: root,
+ version: 'question-test',
+ daemonPromptId: promptId,
+ message: { role: type === 'assistant' ? 'model' : 'user', parts },
+ });
+ return uuid;
+ },
+ () => true,
+ undefined,
+ undefined,
+ {
+ settings: {
+ mode,
+ timeoutMs: terminal === 'expired' ? 1000 : 60000,
+ },
+ waiters: ownerWaiters,
+ },
+ { profile, publicPlanMode: planMode, ...extra },
+ );
+ await createManagedHarnessHandle(session).ensureRunnable();
+ if (!planMode) {
+ for (const extra of [{ childDepth: 1 }, { foregroundOnly: true }])
+ expect(
+ (
+ await makeTurn(session, waiters, extra).declarations(
+ controller.signal,
+ )
+ ).map((tool) => tool.name),
+ ).not.toContain('ask_user_question');
+ }
+ const turn = makeTurn(session, waiters);
+ const classifier = vi.fn();
+ turn.setPermissionClassifier(classifier);
+ expect(
+ (await turn.declarations(controller.signal)).map((tool) => tool.name),
+ ).toContain('ask_user_question');
+ const call = {
+ name: 'ask_user_question',
+ callId: 'question-call',
+ args: input,
+ isClientInitiated: false,
+ prompt_id: promptId,
+ };
+ const calls = mixed
+ ? [
+ call,
+ {
+ ...call,
+ name: 'write_file',
+ callId: 'mixed-write',
+ args: { file_path: 'must-not-exist', content: 'No effects' },
+ },
+ ]
+ : [call];
+ running = turn
+ .execute(
+ calls,
+ calls.map((call) => ({
+ functionCall: {
+ id: call.callId,
+ name: call.name,
+ args: call.args,
+ },
+ })),
+ 'model',
+ controller.signal,
+ )
+ .catch((error) => error);
+ if (mixed) {
+ expect(JSON.stringify(await running)).toContain(
+ 'must be the only call',
+ );
+ expect(
+ session.authority
+ .eventsInSequenceRange(1, session.authority.committedSequence)
+ .some((event) => event.kind === 'action.changed'),
+ ).toBe(false);
+ expect(HostedWorkspaceBroker.prototype.acquire).not.toHaveBeenCalled();
+ expect(prepare).not.toHaveBeenCalled();
+ expect(execute).not.toHaveBeenCalled();
+ return;
+ }
+ let actionId = '';
+ await vi.waitFor(
+ async () => {
+ const auth = await session.authority.harnessRunAuthorization();
+ expect(auth).toMatchObject({
+ status: 'runnable',
+ checkpoint: { continuation: { phase: 'await_action' } },
+ });
+ if (auth.status === 'runnable')
+ actionId = auth.checkpoint.approval!.requestId;
+ },
+ { timeout: 15000 },
+ );
+ const action = session.authority.action(actionId)!;
+ const options = await readHostedActionOptions(session, action);
+ expect(options).toMatchObject({
+ v: 4,
+ requiresHumanDecision: true,
+ policyRevision: HOSTED_QUESTION_POLICY,
+ });
+ expect(action.kind).toBe('question');
+ await hostedLiveApprovalPoll(
+ session,
+ waiters,
+ options,
+ async () => ({ mode: 'yolo', timeoutMs: 60000 }),
+ controller.signal,
+ )();
+ expect(session.authority.action(actionId)?.state).toBe('requested');
+ expect(classifier).not.toHaveBeenCalled();
+ const body = {
+ answers: [
+ {
+ questionId: 'q1',
+ optionIds: ['q1o2', 'q1o1'],
+ text: 'Custom, with comma',
+ },
+ ],
+ inputRevision: 1,
+ policyRevision: HOSTED_QUESTION_POLICY,
+ };
+ expect(
+ (
+ await resolveHostedAction(session, waiters, actionId, {
+ optionId: 'allow',
+ inputRevision: 1,
+ policyRevision: HOSTED_QUESTION_POLICY,
+ })
+ ).status,
+ ).toBe(400);
+ if (terminal) {
+ if (terminal === 'cancelled') controller.abort();
+ const result = await running;
+ if (terminal === 'cancelled')
+ expect(result).toMatchObject({ name: 'AbortError' });
+ else expect(result).not.toBeInstanceOf(Error);
+ expect(session.authority.action(actionId)?.state).toBe(terminal);
+ expect(session.authority.action(actionId)?.decisionRef).toBeNull();
+ expect(
+ (await resolveHostedAction(session, waiters, actionId, body)).status,
+ ).toBe(409);
+ expect(JSON.stringify(result)).not.toContain(
+ 'User provided the following answers',
+ );
+ expect(prepare).not.toHaveBeenCalled();
+ expect(execute).not.toHaveBeenCalled();
+ return;
+ }
+ if (restart === 'decided')
+ expect(
+ (
+ await resolveHostedAction(
+ session,
+ new HostedApprovalWaiters(),
+ actionId,
+ body,
+ )
+ ).status,
+ ).toBe(200);
+ if (restart !== 'none') {
+ await session.close();
+ controller.abort();
+ await running;
+ session = await openManagedSession({
+ ...sessionOptions,
+ workerId: 'replacement',
+ });
+ expect(session.authority.action(actionId)?.state).toBe(
+ restart === 'waiting' ? 'requested' : 'decided',
+ );
+ }
+ expect(
+ (await resolveHostedAction(session, waiters, actionId, body)).status,
+ ).toBe(200);
+ expect(
+ (
+ await resolveHostedAction(session, waiters, actionId, {
+ ...body,
+ answers: [{ ...body.answers[0], optionIds: ['q1o1', 'q1o2'] }],
+ })
+ ).status,
+ ).toBe(200);
+ expect(
+ (
+ await resolveHostedAction(session, waiters, actionId, {
+ ...body,
+ answers: [{ questionId: 'q1', optionIds: ['q1o1'] }],
+ })
+ ).status,
+ ).toBe(409);
+ let result;
+ if (restart === 'none') result = await running;
+ else {
+ const saved = await findHostedApprovalContinuation(session, promptId);
+ expect(saved?.plan.actionId).toBe(actionId);
+ const harness = createManagedHarnessHandle(session);
+ await harness.resolveDurableWait();
+ await harness.adoptPreparedContinuation(promptId);
+ result = await makeTurn(
+ session,
+ new HostedApprovalWaiters(),
+ ).resumeSavedNativeBatch(saved!, 1, new AbortController().signal);
+ }
+ expect(result).not.toBeInstanceOf(Error);
+ expect(JSON.stringify(result)).toContain('Selected q1o2: Read, write');
+ expect(JSON.stringify(result)).toContain('Custom, with comma');
+ expect(prepare).not.toHaveBeenCalled();
+ expect(execute).not.toHaveBeenCalled();
+ expect(
+ (await session.sink.project()).filter(
+ (item) => item.type === 'tool_result',
+ ),
+ ).toHaveLength(1);
+ expect(
+ await findHostedApprovalContinuation(session, promptId),
+ ).toBeUndefined();
+ if (planMode)
+ expect(await hostedPublicPlanActive(session, promptId)).toBe(true);
+ await endHostedAction(session, actionId, 'cancelled');
+ expect(session.authority.action(actionId)?.state).toBe('decided');
+ } finally {
+ controller.abort();
+ if (running) await running;
+ await session.close();
+ await rm(root, { recursive: true, force: true });
+ }
+ },
+ 45000,
+);
diff --git a/packages/cli/src/serve/hosted-user-question.ts b/packages/cli/src/serve/hosted-user-question.ts
new file mode 100644
index 00000000000..fe881e2f38c
--- /dev/null
+++ b/packages/cli/src/serve/hosted-user-question.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createHash } from 'node:crypto';
+import type { FunctionDeclaration } from '@google/genai';
+import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
+import type { ManagedSessionAction } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js';
+import type { HostedActionOptions } from './hosted-tool-approval.js';
+
+export const HOSTED_QUESTION_POLICY = 'hosted-user-question/1';
+export const HOSTED_QUESTION_INPUT_BYTES = 16 * 1024;
+export const HOSTED_QUESTION_ANSWER_BYTES = 32 * 1024;
+
+export interface HostedActionQuestion {
+ readonly id: string;
+ readonly prompt: string;
+ readonly required: true;
+ readonly multiple: boolean;
+ readonly allowText: true;
+ readonly options: ReadonlyArray<{
+ readonly id: string;
+ readonly label: string;
+ readonly description: string;
+ }>;
+}
+
+export interface HostedQuestionAnswer {
+ readonly questionId: string;
+ readonly optionIds?: readonly string[];
+ readonly text?: string;
+}
+
+export const HOSTED_USER_QUESTION_TOOL: FunctionDeclaration = {
+ name: 'ask_user_question',
+ description:
+ 'Ask the user to clarify requirements or choose an approach. Use this tool as the only call in its batch and wait for the answer. Users can always enter custom text. Put a recommended choice first with "(Recommended)" in its label. In Plan mode, use exit_plan_mode for approval of the finished plan.',
+ parametersJsonSchema: {
+ type: 'object',
+ properties: {
+ questions: {
+ type: 'array',
+ minItems: 1,
+ maxItems: 4,
+ items: {
+ type: 'object',
+ properties: {
+ question: { type: 'string', minLength: 1, maxLength: 2048 },
+ header: { type: 'string', minLength: 1 },
+ options: {
+ type: 'array',
+ minItems: 2,
+ maxItems: 4,
+ items: {
+ type: 'object',
+ properties: {
+ label: { type: 'string', minLength: 1, maxLength: 256 },
+ description: {
+ type: 'string',
+ minLength: 1,
+ maxLength: 1024,
+ },
+ },
+ required: ['label', 'description'],
+ additionalProperties: false,
+ },
+ },
+ multiSelect: { type: 'boolean' },
+ },
+ required: ['question', 'header', 'options'],
+ additionalProperties: false,
+ },
+ },
+ metadata: {
+ type: 'object',
+ properties: { source: { type: 'string' } },
+ additionalProperties: false,
+ },
+ },
+ required: ['questions'],
+ additionalProperties: false,
+ },
+};
+
+export class HostedQuestionInputError extends Error {}
+
+function object(
+ value: unknown,
+ required: readonly string[],
+ optional: readonly string[] = [],
+): Record {
+ if (
+ !value ||
+ typeof value !== 'object' ||
+ Array.isArray(value) ||
+ required.some((key) => !Object.hasOwn(value, key)) ||
+ Object.keys(value).some(
+ (key) => !required.includes(key) && !optional.includes(key),
+ )
+ )
+ throw new HostedQuestionInputError('Invalid question or answer fields.');
+ return value as Record;
+}
+
+function text(value: unknown, max: number, nonblank = true): string {
+ if (
+ typeof value !== 'string' ||
+ (nonblank && !value.trim()) ||
+ value.length > max ||
+ /[\uD800-\uDFFF]/u.test(value)
+ )
+ throw new HostedQuestionInputError(
+ `Question text must be valid Unicode, nonblank when required, and at most ${max} characters.`,
+ );
+ return value;
+}
+
+export function hostedQuestions(input: unknown): HostedActionQuestion[] {
+ const args = object(input, ['questions'], ['metadata']);
+ if (Buffer.byteLength(JSON.stringify(args)) > HOSTED_QUESTION_INPUT_BYTES)
+ throw new HostedQuestionInputError('Question input exceeds 16 KiB.');
+ if (Object.hasOwn(args, 'metadata')) {
+ const metadata = object(args['metadata'], [], ['source']);
+ if (Object.hasOwn(metadata, 'source'))
+ text(metadata['source'], HOSTED_QUESTION_INPUT_BYTES, false);
+ }
+ const questions = args['questions'];
+ if (!Array.isArray(questions) || questions.length < 1 || questions.length > 4)
+ throw new HostedQuestionInputError('Ask between one and four questions.');
+ return questions.map((value, index) => {
+ const question = object(
+ value,
+ ['question', 'header', 'options'],
+ ['multiSelect'],
+ );
+ text(question['header'], HOSTED_QUESTION_INPUT_BYTES);
+ if (
+ Object.hasOwn(question, 'multiSelect') &&
+ typeof question['multiSelect'] !== 'boolean'
+ )
+ throw new HostedQuestionInputError('multiSelect must be a boolean.');
+ const options = question['options'];
+ if (!Array.isArray(options) || options.length < 2 || options.length > 4)
+ throw new HostedQuestionInputError(
+ 'Provide between two and four options.',
+ );
+ return {
+ id: `q${index + 1}`,
+ prompt: text(question['question'], 2048),
+ required: true,
+ multiple: question['multiSelect'] === true,
+ allowText: true,
+ options: options.map((value, optionIndex) => {
+ const option = object(value, ['label', 'description']);
+ return {
+ id: `q${index + 1}o${optionIndex + 1}`,
+ label: text(option['label'], 256),
+ description: text(option['description'], 1024),
+ };
+ }),
+ };
+ });
+}
+
+export function assertHostedQuestions(
+ value: unknown,
+): asserts value is HostedActionQuestion[] {
+ if (!Array.isArray(value) || value.length < 1 || value.length > 4)
+ throw new HostedQuestionInputError('Invalid saved questions.');
+ for (const [index, item] of value.entries()) {
+ const question = object(item, [
+ 'id',
+ 'prompt',
+ 'required',
+ 'multiple',
+ 'allowText',
+ 'options',
+ ]);
+ text(question['prompt'], 2048);
+ const options = question['options'];
+ if (
+ question['id'] !== `q${index + 1}` ||
+ question['required'] !== true ||
+ question['allowText'] !== true ||
+ typeof question['multiple'] !== 'boolean' ||
+ !Array.isArray(options) ||
+ options.length < 2 ||
+ options.length > 4
+ )
+ throw new HostedQuestionInputError('Invalid saved question shape.');
+ for (const [optionIndex, value] of options.entries()) {
+ const option = object(value, ['id', 'label', 'description']);
+ if (option['id'] !== `q${index + 1}o${optionIndex + 1}`)
+ throw new HostedQuestionInputError('Invalid saved option ID.');
+ text(option['label'], 256);
+ text(option['description'], 1024);
+ }
+ }
+}
+
+export function normalizeHostedQuestionAnswers(
+ questions: readonly HostedActionQuestion[],
+ value: unknown,
+): HostedQuestionAnswer[] {
+ if (!Array.isArray(value) || value.length !== questions.length)
+ throw new HostedQuestionInputError('Answer every question exactly once.');
+ const answers = new Map>();
+ for (const valueItem of value) {
+ const answer = object(valueItem, ['questionId'], ['optionIds', 'text']);
+ const id = answer['questionId'];
+ if (
+ typeof id !== 'string' ||
+ !questions.some((q) => q.id === id) ||
+ answers.has(id)
+ )
+ throw new HostedQuestionInputError('Unknown or duplicate question ID.');
+ answers.set(id, answer);
+ }
+ const normalized = questions.map((question) => {
+ const answer = answers.get(question.id)!;
+ const rawIds = Object.hasOwn(answer, 'optionIds')
+ ? answer['optionIds']
+ : undefined;
+ if (
+ rawIds !== undefined &&
+ (!Array.isArray(rawIds) ||
+ rawIds.length === 0 ||
+ rawIds.length > question.options.length ||
+ new Set(rawIds).size !== rawIds.length ||
+ rawIds.some(
+ (id) => !question.options.some((option) => option.id === id),
+ ))
+ )
+ throw new HostedQuestionInputError(
+ 'Unknown, empty or duplicate option IDs.',
+ );
+ const optionIds = question.options
+ .filter((option) =>
+ (rawIds as unknown[] | undefined)?.includes(option.id),
+ )
+ .map((option) => option.id);
+ const answerText = Object.hasOwn(answer, 'text')
+ ? text(answer['text'], 8192)
+ : undefined;
+ if (
+ (optionIds.length === 0 && answerText === undefined) ||
+ (!question.multiple &&
+ optionIds.length + Number(answerText !== undefined) !== 1)
+ )
+ throw new HostedQuestionInputError(
+ 'Choose one answer for a single-choice question, or provide a nonempty multiple-choice answer.',
+ );
+ return {
+ questionId: question.id,
+ ...(optionIds.length ? { optionIds } : {}),
+ ...(answerText !== undefined ? { text: answerText } : {}),
+ };
+ });
+ if (
+ Buffer.byteLength(JSON.stringify(normalized)) > HOSTED_QUESTION_ANSWER_BYTES
+ )
+ throw new HostedQuestionInputError('Question answers exceed 32 KiB.');
+ return normalized;
+}
+
+export function hostedQuestionDecisionBytes(
+ answers: readonly HostedQuestionAnswer[],
+ inputRevision: number,
+ policyRevision: string,
+): Buffer {
+ return Buffer.from(
+ JSON.stringify({ v: 2, answers, inputRevision, policyRevision }),
+ );
+}
+
+export async function readHostedQuestionDecision(
+ session: ManagedSession,
+ action: ManagedSessionAction,
+ options: Extract,
+): Promise {
+ if (
+ action.state !== 'decided' ||
+ action.decisionRef?.kind !== 'managed-action-decision'
+ )
+ throw new Error('Question has no durable answer.');
+ const bytes = await session.resources.read(action.decisionRef);
+ const decision = object(JSON.parse(bytes.toString('utf8')), [
+ 'v',
+ 'answers',
+ 'inputRevision',
+ 'policyRevision',
+ ]);
+ const answers = normalizeHostedQuestionAnswers(
+ options.questions,
+ decision['answers'],
+ );
+ const canonical = hostedQuestionDecisionBytes(
+ answers,
+ options.inputRevision,
+ options.policyRevision,
+ );
+ if (
+ decision['v'] !== 2 ||
+ decision['inputRevision'] !== action.inputRevision ||
+ decision['policyRevision'] !== HOSTED_QUESTION_POLICY ||
+ !bytes.equals(canonical) ||
+ createHash('sha256').update(canonical).digest('hex') !==
+ action.decisionRef.digest
+ )
+ throw new Error('Question answer conflicts with its original Action.');
+ return answers;
+}
diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.ts
index b746826432c..c4a648f66be 100644
--- a/packages/cli/src/serve/hosted-workspace-tool-turn.ts
+++ b/packages/cli/src/serve/hosted-workspace-tool-turn.ts
@@ -257,6 +257,13 @@ import {
type HostedApprovalSettings,
type HostedApprovalWaiters,
} from './hosted-tool-approval.js';
+import {
+ HOSTED_USER_QUESTION_TOOL,
+ HOSTED_QUESTION_POLICY,
+ HostedQuestionInputError,
+ hostedQuestions,
+ readHostedQuestionDecision,
+} from './hosted-user-question.js';
export {
HOSTED_WORKSPACE_FILE_PROFILE,
@@ -1822,6 +1829,7 @@ interface HostedPreparedRequest {
command: boolean;
skill: boolean;
planExit?: boolean;
+ question?: boolean;
todo?: boolean;
}
@@ -1968,6 +1976,7 @@ export class HostedWorkspaceToolTurn {
profile?: string;
context?: HostedWorkspaceContextSlot;
foregroundOnly?: boolean;
+ childDepth?: number;
childRuns?: HostedChildRunSession;
monitors?: HostedMonitorSession;
backgroundLane?: HostedShellTurnOptions;
@@ -2030,7 +2039,7 @@ export class HostedWorkspaceToolTurn {
? launchEnabled
: () => launchEnabled !== false;
this.childSnapshot = extras?.childAgents?.snapshot;
- this.childDepth = extras?.childAgents?.depth ?? 0;
+ this.childDepth = extras?.childDepth ?? extras?.childAgents?.depth ?? 0;
this.childWorkspaces = extras?.childAgents?.childWorkspaces === true;
this.childSnapshots =
this.childWorkspaces && extras?.childAgents?.childSnapshots === true;
@@ -2095,6 +2104,24 @@ export class HostedWorkspaceToolTurn {
return this.profile === HOSTED_WORKSPACE_FILE_PROFILE_V3;
}
+ private questionsAdmitted(): boolean {
+ return (
+ this.approval !== undefined &&
+ !this.hooks &&
+ !this.mcp &&
+ !this.foregroundOnly &&
+ !this.residentWorkspace &&
+ this.childDepth === 0 &&
+ !this.memberPlanMode &&
+ [
+ HOSTED_WORKSPACE_FILE_PROFILE,
+ HOSTED_WORKSPACE_FILE_PROFILE_V2,
+ HOSTED_WORKSPACE_FILE_PROFILE_V3,
+ HOSTED_WORKSPACE_SHELL_PROFILE,
+ ].includes(this.profile ?? HOSTED_WORKSPACE_FILE_PROFILE)
+ );
+ }
+
/**
* H4b: an Agent call is admitted exactly when this Shell-laned Session
* owns its child orchestrator (a child Session's own turn does not),
@@ -2327,6 +2354,8 @@ export class HostedWorkspaceToolTurn {
: tool,
);
}
+ if (this.questionsAdmitted())
+ this.advertised.push(HOSTED_USER_QUESTION_TOOL);
if (this.publicPlanMode) this.advertised.push(HOSTED_PUBLIC_PLAN_TOOL);
if (this.publicChildren && this.extensionAgents.length)
this.advertised = this.advertised.map((tool) =>
@@ -3621,6 +3650,7 @@ export class HostedWorkspaceToolTurn {
command: false,
skill: item.call.name === 'skill' && this.skills !== undefined,
planExit: this.publicPlanMode && item.call.name === 'exit_plan_mode',
+ question: item.call.name === 'ask_user_question',
});
}
this.logicalRound = plan.round;
@@ -3648,6 +3678,8 @@ export class HostedWorkspaceToolTurn {
this.publicPlanMode &&
batch?.requests.length === 1 &&
batch.requests[0].planExit === true;
+ const singleQuestion =
+ batch?.requests.length === 1 && batch.requests[0].question === true;
if (
!batch ||
batch.uncaptured ||
@@ -3656,12 +3688,14 @@ export class HostedWorkspaceToolTurn {
(!commandStep &&
isHostedWorkspaceShellProfile(this.profile) &&
!singlePlanExit &&
+ !singleQuestion &&
!this.publicChildren) ||
(!commandStep && (this.hooks || this.mcp)) ||
batch.requests.some(
(request) =>
!(
singlePlanExit ||
+ singleQuestion ||
(commandStep && request.call.name === 'skill') ||
(this.publicChildren &&
['agent', 'run_shell_command'].includes(request.call.name)) ||
@@ -3905,6 +3939,17 @@ export class HostedWorkspaceToolTurn {
) {
validationError = HOSTED_PUBLIC_PLAN_REFUSAL;
input = { ...call.args };
+ } else if (call.name === 'ask_user_question') {
+ input = { ...call.args };
+ try {
+ hostedQuestions(input);
+ if (source.length !== 1 || !this.questionsAdmitted())
+ validationError =
+ 'ask_user_question must be the only call in a supported root Session batch.';
+ } catch (cause) {
+ if (!(cause instanceof HostedQuestionInputError)) throw cause;
+ validationError = cause.message;
+ }
} else if (planExit) {
input = { ...call.args };
if (!this.planning)
@@ -4607,6 +4652,7 @@ export class HostedWorkspaceToolTurn {
command,
skill,
planExit,
+ question: call.name === 'ask_user_question',
todo: call.name === 'todo_write',
};
});
@@ -4971,6 +5017,7 @@ export class HostedWorkspaceToolTurn {
request.team ||
request.command ||
request.planExit ||
+ request.question ||
request.skill ||
request.todo ||
request.workflow
@@ -5427,6 +5474,53 @@ export class HostedWorkspaceToolTurn {
);
continue;
}
+ if (request.question) {
+ const actionId = this.nativeBatch?.actionIds.get(index);
+ const action = actionId && this.session.authority.action(actionId);
+ if (!action)
+ throw new HostedToolRecoveryRequiredError(
+ 'Question Action is missing.',
+ );
+ const options = await readHostedActionOptions(this.session, action);
+ if (options.v !== 4)
+ throw new HostedToolRecoveryRequiredError(
+ 'Question Action has incompatible options.',
+ );
+ const answers = await readHostedQuestionDecision(
+ this.session,
+ action,
+ options,
+ );
+ const answerText = answers
+ .map((answer, ordinal) => {
+ const question = options.questions[ordinal];
+ const selected = question.options.filter((option) =>
+ answer.optionIds?.includes(option.id),
+ );
+ return [
+ question.prompt,
+ ...selected.map(
+ (option) => `Selected ${option.id}: ${option.label}`,
+ ),
+ ...(answer.text !== undefined
+ ? [`Custom answer: ${answer.text}`]
+ : []),
+ ].join('\n');
+ })
+ .join('\n\n');
+ const converted = convertToFunctionResponse(
+ request.call.name,
+ request.call.callId,
+ [{ text: `User provided the following answers:\n\n${answerText}` }],
+ );
+ if (!this.messageFitsInline('tool_result', converted, model))
+ throw new HostedToolRecoveryRequiredError(
+ 'Question result exceeds the Session Store limit.',
+ );
+ await this.commit('tool_result', converted, model);
+ responses.push(...converted);
+ continue;
+ }
if (request.planExit) {
if (await hostedPublicPlanActive(this.session, this.promptId))
throw new HostedToolRecoveryRequiredError(
@@ -8468,6 +8562,7 @@ export class HostedWorkspaceToolTurn {
continue;
}
const humanOnly =
+ request.question === true ||
asks.has(index) ||
decision === 'ask' ||
(this.publicPlanMode &&
@@ -8877,31 +8972,49 @@ export class HostedWorkspaceToolTurn {
? undefined
: await this.captureNativeBatch('approval', requestId, ordinal);
if (ordinal >= 0 && !continuationRef) this.nativeBatch!.uncaptured = true;
- const options: HostedActionOptions = {
- ...(continuationRef
- ? {
- v: 3 as const,
- continuationRef,
- ...(HOSTED_INPUT_PREVIEW_TOOLS.includes(call.name)
- ? { inputRef }
- : {}),
- }
- : HOSTED_INPUT_PREVIEW_TOOLS.includes(call.name)
- ? { v: 2 as const, inputRef }
- : { v: 1 as const }),
+ const question = call.name === 'ask_user_question';
+ if (question && !continuationRef)
+ throw new HostedToolRecoveryRequiredError(
+ 'Question continuation is unavailable.',
+ );
+ const identity = {
requestId,
turnId: this.promptId,
functionCallId: call.callId,
toolName: call.name,
- policyRevision: HOSTED_TOOL_APPROVAL_POLICY,
inputRevision: 1,
createdAt,
expiresAt: createdAt + approval.settings.timeoutMs,
- options: HOSTED_APPROVAL_OPTIONS,
- ...(requiresHumanDecision
- ? { requiresHumanDecision: true as const }
- : {}),
};
+ const options: HostedActionOptions = question
+ ? {
+ ...identity,
+ v: 4,
+ policyRevision: HOSTED_QUESTION_POLICY,
+ requiresHumanDecision: true,
+ inputRef,
+ continuationRef: continuationRef!,
+ questions: hostedQuestions(call.args),
+ }
+ : {
+ ...(continuationRef
+ ? {
+ v: 3 as const,
+ continuationRef,
+ ...(HOSTED_INPUT_PREVIEW_TOOLS.includes(call.name)
+ ? { inputRef }
+ : {}),
+ }
+ : HOSTED_INPUT_PREVIEW_TOOLS.includes(call.name)
+ ? { v: 2 as const, inputRef }
+ : { v: 1 as const }),
+ ...identity,
+ policyRevision: HOSTED_TOOL_APPROVAL_POLICY,
+ options: HOSTED_APPROVAL_OPTIONS,
+ ...(requiresHumanDecision
+ ? { requiresHumanDecision: true as const }
+ : {}),
+ };
const optionsRef = await this.session.resources.publish(
'managed-action-options',
Buffer.from(JSON.stringify(options)),
@@ -8910,7 +9023,7 @@ export class HostedWorkspaceToolTurn {
await this.harness.commitDurableWait(
{
requestId,
- kind: 'permission',
+ kind: question ? 'question' : 'permission',
source: 'tool_call',
optionsRef,
inputRevision: '1',
@@ -8965,6 +9078,10 @@ export class HostedWorkspaceToolTurn {
);
await this.harness.resolveDurableWait();
const action = authority.action(requestId)!;
+ if (action.state === 'decided' && options.v === 4) {
+ await readHostedQuestionDecision(this.session, action, options);
+ return undefined;
+ }
if (action.state === 'decided')
return hostedActionAllowed(action, options.policyRevision)
? undefined
diff --git a/packages/managed-agent-server/README.md b/packages/managed-agent-server/README.md
index 7b7e8342eba..5ec1a82905f 100644
--- a/packages/managed-agent-server/README.md
+++ b/packages/managed-agent-server/README.md
@@ -947,6 +947,43 @@ The initial approval mode is pinned at Session creation and must be confirmed by
Harness on creation and load. Built-in Workspace Sessions can select a different
policy for later Turns while retaining the original definition.
+### Structured user questions
+
+Root Sessions using `hosted-workspace-files/1`, `/2`, `/3`, or
+`hosted-workspace-shell/1` expose `ask_user_question`, including public Plan
+mode. The tool asks one to four required questions, each offering two to four
+choices and a custom-text answer. Single-choice questions accept one option or
+custom text; multiple-choice questions also allow both together. The tool must
+be the only call in its batch. MCP/Hook Sessions, child Sessions,
+foreground-only workers and resident CSI profiles do not expose it.
+
+The question appears as a pending Action with `kind: question`. WebShell keeps
+the answer draft visible until delivery is confirmed; refreshing retrieves the
+same pending Action. Public responses use `kind: question`, `answers` with
+`question_id`, optional `option_ids` and optional `text`, plus the Action's
+`input_revision` and `policy_revision`. WebShell uses their camelCase names.
+Use the returned question and option IDs, not their labels, and the existing
+Action response endpoints and idempotency key. Response authorization is the
+same as permission Actions, including the Shell owner restriction.
+
+Questions always wait for a human answer, including Full Access and live
+approval-policy changes. The existing approval timeout, cancellation and
+expiry rules apply. Invalid or conflicting answers cannot replace the durable
+decision; an equivalent retry returns the original result. The original
+question and answer survive service/Harness restart and resume the same tool
+call. Answer text is limited to 8192 UTF-16 code units per question and the
+canonical answer array to 32 KiB; the complete tool input is limited to 16 KiB.
+
+Deploy compatible Java server, Java SDK, Harness and WebShell artifacts
+together. Drain and fence older writers before reopening execution; older
+binaries cannot interpret the new question options/decisions and must not
+resume Sessions with pending questions. No database schema change is required.
+See the [question design](../../docs/design/2026-11-06-managed-user-questions.md)
+([简体中文](../../docs/design/2026-11-06-managed-user-questions.zh-CN.md)) for
+the continuation contract and acceptance scope.
+
+### Approval policy selection
+
For new Workspace-bound WebShell Sessions, the shared composer queries `POST /api/agent/web-shell/v1/session-options/query` with `agentId` using the authenticated tenant actor. The response contains the effective `approvalMode` and `availableApprovalModes`; Workspace discovery advertises `approvalModeSelection` for this contract. Built-in Sessions allow `default`, `auto-edit`, and `yolo` (Full Access). Shell Full Access additionally requires the Harness feature `approval-selection/1`; without it Shell offers only `default` and `auto-edit`. The Shell deployment opt-in and its deployment default remain unchanged. Hosted execution does not support classifier `auto` or `plan`. An AgentDefinition exposes only its compiled policy, which a creation request cannot override.
The WebShell create request may include `approvalMode` only with a Workspace binding. Omission preserves the existing default and request digest; explicit selection is persisted in the Session and joins its idempotency digest. Retrying the same key reads the original pin even if deployment defaults, the AgentDefinition, or a later Session selection changed. Workspace-bound and definition-pinned Session summaries expose the current `approvalMode`, immutable `initialApprovalMode`, and `approvalRevision`. Legacy unbound built-in Sessions omit this metadata because their execution policy is supplied by the deployment. The public REST create contract is unchanged.
diff --git a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java
index 00e31ab9e7a..4bfeb2f3cbe 100644
--- a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java
+++ b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java
@@ -40,6 +40,7 @@ import javax.net.ssl.SSLContext;
import java.net.URI;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -1087,12 +1088,31 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
session.workspace() != null || workspaceExecution.verifiedRecoveryEnabled(), true);
ref = attachments.get(key);
}
- client().resolveAction(
+ if (response.has("answers")) {
+ List
)}
- {
- if (!compression.isBlocked())
- return approvals.respond(actionId, optionId);
- return Promise.resolve();
- }}
- />
- {pendingApproval.rawInput === undefined && (
-
- {approvals.action?.inputPreview
- ? t(
- approvals.action.inputPreview.truncated
- ? 'managed.approval.previewTruncated'
- : 'managed.approval.previewComplete',
- { bytes: approvals.action.inputPreview.byteLength },
- )
- : t('managed.approval.argumentsUnavailable')}
-
+ {approvals.action?.kind === 'question' ? (
+ {
+ if (compression.isBlocked())
+ return Promise.reject(
+ new Error('Context compression is pending'),
+ );
+ return approvals.respond(pendingApproval.id, response);
+ }}
+ />
+ ) : (
+ {
+ if (!compression.isBlocked())
+ return approvals.respond(actionId, optionId);
+ return Promise.resolve();
+ }}
+ />
)}
+ {approvals.action?.kind !== 'question' &&
+ pendingApproval.rawInput === undefined && (
+
+ {approvals.action?.inputPreview
+ ? t(
+ approvals.action.inputPreview.truncated
+ ? 'managed.approval.previewTruncated'
+ : 'managed.approval.previewComplete',
+ { bytes: approvals.action.inputPreview.byteLength },
+ )
+ : t('managed.approval.argumentsUnavailable')}
+
+ )}
)}
{approvals.loadError !== undefined && (
@@ -2207,8 +2224,12 @@ function ManagedSessionsContent({
>
{t(
approvals.respondForbidden
- ? 'managed.approval.forbidden'
- : 'managed.approval.failed',
+ ? approvals.action?.kind === 'question'
+ ? 'managed.question.forbidden'
+ : 'managed.approval.forbidden'
+ : approvals.action?.kind === 'question'
+ ? 'managed.question.failed'
+ : 'managed.approval.failed',
)}
{approvals.respondForbidden && (
diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts
index e03a0ffcbad..98c65140ea5 100644
--- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts
+++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts
@@ -177,7 +177,7 @@ export interface paths {
};
get?: never;
put?: never;
- /** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
+ /** @description Serves Hosted permission and structured question Actions. Question answers preserve the original question and option IDs, allow one to four answers, and are bounded to 32 KiB of canonical UTF-8 JSON. vote_recorded remains planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
post: operations["queryWebShellActions"];
delete?: never;
options?: never;
@@ -194,7 +194,7 @@ export interface paths {
};
get?: never;
put?: never;
- /** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
+ /** @description Serves Hosted permission and structured question Actions. Question answers preserve the original question and option IDs, allow one to four answers, and are bounded to 32 KiB of canonical UTF-8 JSON. vote_recorded remains planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
post: operations["getWebShellAction"];
delete?: never;
options?: never;
@@ -211,7 +211,7 @@ export interface paths {
};
get?: never;
put?: never;
- /** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
+ /** @description Serves Hosted permission and structured question Actions. Question answers preserve the original question and option IDs, allow one to four answers, and are bounded to 32 KiB of canonical UTF-8 JSON. vote_recorded remains planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
post: operations["respondWebShellAction"];
delete?: never;
options?: never;
@@ -1667,7 +1667,7 @@ export interface components {
questionId: string;
optionIds?: string[];
text?: string;
- } | unknown | unknown;
+ } & (unknown | unknown);
WebShellActionQuestion: {
id: string;
prompt: string;
@@ -1714,6 +1714,9 @@ export interface components {
/** Format: int64 */
expiresAt: number;
decisionReceiptId?: string;
+ turnId?: string;
+ functionCallId: string;
+ toolName: string;
questions: components["schemas"]["WebShellActionQuestion"][];
} & unknown;
/** @description At most one requested Hosted approval per Turn. allow and deny are stable option IDs. A requested native-tool Action may carry a bounded input preview when the producer supplies an input reference. Version 1 Actions and unavailable inputs omit it. Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs. */
@@ -1781,7 +1784,7 @@ export interface components {
/** @default 20 */
limit?: number;
/**
- * @description Include pending permission Actions of this public root Session and its direct children; each Action retains its actual owning sessionId.
+ * @description Include pending Actions of this public root Session and its direct children; each Action retains its actual owning sessionId.
* @default false
*/
includeChildren?: boolean;
diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts
index 28d30387b66..4014aed7afd 100644
--- a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts
+++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { artifact } from './managed-tool-result.test-fixtures';
import { createJavaManagedAgentProvider } from './java-managed-agent-provider';
+import { pendingQuestion } from './managed-question.test-fixtures';
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), {
@@ -1954,6 +1955,7 @@ describe('createJavaManagedAgentProvider', () => {
...permission,
actionId: 'question_1',
kind: 'question',
+ state: 'decided',
questions: [],
},
],
@@ -2098,6 +2100,69 @@ describe('createJavaManagedAgentProvider', () => {
},
);
+ it('lists a durable question and submits its structured answer through the existing operation', async () => {
+ const question = pendingQuestion();
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse({
+ data: [
+ {
+ ...question,
+ source: { kind: 'tool_call', id: question.functionCallId },
+ state: 'requested',
+ createdAt: 1,
+ },
+ ],
+ hasMore: false,
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ operationId: 'question-response',
+ type: 'action_response',
+ status: 'completed',
+ }),
+ );
+ const provider = createJavaManagedAgentProvider({
+ baseUrl: 'https://product.example',
+ fetch: fetchImpl,
+ });
+ const pending = await provider.actions!.listPending(question.sessionId, {
+ clientId: 'client',
+ });
+ expect(pending).toEqual([question]);
+ const response = {
+ kind: 'question' as const,
+ answers: [
+ { questionId: 'q1', optionIds: ['q1o2'] },
+ { questionId: 'q2', optionIds: ['q2o1', 'q2o2'], text: 'custom' },
+ ],
+ };
+ await provider.actions!.respond(question, response, {
+ clientId: 'client',
+ idempotencyKey: 'question:stable-digest',
+ });
+ expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({
+ requestId: expect.any(String),
+ sessionId: question.sessionId,
+ actionId: question.actionId,
+ idempotencyKey: 'question:stable-digest',
+ response: {
+ ...response,
+ inputRevision: question.inputRevision,
+ policyRevision: question.policyRevision,
+ },
+ });
+ await expect(
+ provider.actions!.respond(question, 'allow', {
+ clientId: 'client',
+ idempotencyKey: 'invalid',
+ }),
+ ).rejects.toThrow('kind does not match');
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
describe('an accepted approval answer', () => {
const action = {
actionId: 'tool_approval_1',
diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts
index bb06f3d08ae..f14809fb048 100644
--- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts
+++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts
@@ -654,7 +654,10 @@ export function createJavaManagedAgentProvider(
).values(),
];
},
- async respond(action, optionId, command) {
+ async respond(action, response, command) {
+ if ((action.kind === 'question') !== (typeof response !== 'string')) {
+ throw new Error('Action response kind does not match the request');
+ }
const accepted = await client.respondAction(
{
requestId: managedRequestId(),
@@ -662,10 +665,11 @@ export function createJavaManagedAgentProvider(
sessionId: action.sessionId,
actionId: action.actionId,
response: {
- kind: 'permission',
inputRevision: action.inputRevision,
policyRevision: action.policyRevision,
- optionId,
+ ...(typeof response === 'string'
+ ? { kind: 'permission' as const, optionId: response }
+ : response),
},
},
command.signal,
@@ -1178,7 +1182,7 @@ function requireContextCompressionOperation(
}
function toPendingAction(action: JavaAgentAction): ManagedAgentPendingAction[] {
- if (action.kind !== 'permission' || action.state !== 'requested') return [];
+ if (action.state !== 'requested') return [];
return [
{
actionId: action.actionId,
@@ -1189,8 +1193,14 @@ function toPendingAction(action: JavaAgentAction): ManagedAgentPendingAction[] {
inputRevision: action.inputRevision,
policyRevision: action.policyRevision,
expiresAt: action.expiresAt,
- options: action.options.map(({ id, label }) => ({ id, label })),
- ...(action.inputPreview ? { inputPreview: action.inputPreview } : {}),
+ ...(action.kind === 'question'
+ ? { kind: 'question' as const, questions: action.questions }
+ : {
+ options: action.options.map(({ id, label }) => ({ id, label })),
+ ...(action.inputPreview
+ ? { inputPreview: action.inputPreview }
+ : {}),
+ }),
},
];
}
diff --git a/packages/web-shell/client/components/managed/managed-agent-provider.ts b/packages/web-shell/client/components/managed/managed-agent-provider.ts
index 01560dbe538..8f5ee85f191 100644
--- a/packages/web-shell/client/components/managed/managed-agent-provider.ts
+++ b/packages/web-shell/client/components/managed/managed-agent-provider.ts
@@ -325,8 +325,7 @@ export interface ManagedWorkspaceRegistration {
): Promise;
}
-/** A Hosted tool approval waiting for the Session owner's answer. */
-export interface ManagedAgentPendingAction {
+interface ManagedAgentActionIdentity {
actionId: string;
sessionId: string;
/** Public Turn ID; absent when the Turn could not be resolved. */
@@ -336,10 +335,35 @@ export interface ManagedAgentPendingAction {
inputRevision: number;
policyRevision: string;
expiresAt: number;
+}
+
+export interface ManagedAgentPendingPermissionAction
+ extends ManagedAgentActionIdentity {
+ kind?: 'permission';
options: Array<{ id: string; label: string }>;
inputPreview?: components['schemas']['WebShellActionInputPreview'];
}
+export interface ManagedAgentPendingQuestionAction
+ extends ManagedAgentActionIdentity {
+ kind: 'question';
+ questions: components['schemas']['WebShellActionQuestion'][];
+}
+
+export type ManagedAgentPendingAction =
+ | ManagedAgentPendingPermissionAction
+ | ManagedAgentPendingQuestionAction;
+
+export type ManagedAgentQuestionAnswer =
+ components['schemas']['WebShellQuestionAnswer'];
+
+export interface ManagedAgentQuestionResponse {
+ kind: 'question';
+ answers: ManagedAgentQuestionAnswer[];
+}
+
+export type ManagedAgentActionResponse = string | ManagedAgentQuestionResponse;
+
export interface ManagedAgentProvider {
readonly userMessages?: {
query(
@@ -480,7 +504,7 @@ export interface ManagedAgentProvider {
options: ManagedAgentRequestOptions,
): Promise;
};
- /** Present when the provider can serve Hosted permission Actions. */
+ /** Present when the provider can serve Hosted Actions. */
readonly actions?: {
listPending(
sessionId: string,
@@ -488,7 +512,7 @@ export interface ManagedAgentProvider {
): Promise;
respond(
action: ManagedAgentPendingAction,
- optionId: string,
+ response: ManagedAgentActionResponse,
options: ManagedAgentCommandOptions,
): Promise;
};
diff --git a/packages/web-shell/client/components/managed/managed-approval.ts b/packages/web-shell/client/components/managed/managed-approval.ts
index 9a68e087477..c2d2a7fa225 100644
--- a/packages/web-shell/client/components/managed/managed-approval.ts
+++ b/packages/web-shell/client/components/managed/managed-approval.ts
@@ -51,15 +51,17 @@ export function toManagedPermissionRequest(
messages: readonly Message[],
): PermissionRequest {
const tool = findManagedApprovalTool(messages, action);
+ const inputPreview =
+ action.kind === 'question' ? undefined : action.inputPreview;
let rawInput = tool?.args;
if (
!rawInput &&
action.toolName === 'exit_plan_mode' &&
- action.inputPreview &&
- !action.inputPreview.truncated
+ inputPreview &&
+ !inputPreview.truncated
) {
try {
- const preview: unknown = JSON.parse(action.inputPreview.text);
+ const preview: unknown = JSON.parse(inputPreview.text);
if (preview && typeof preview === 'object' && !Array.isArray(preview)) {
const input = (preview as Record)['input'];
if (
@@ -98,18 +100,20 @@ export function toManagedPermissionRequest(
),
},
]
- : action.inputPreview
- ? [{ type: 'text', text: escapePreviewText(action.inputPreview.text) }]
+ : inputPreview
+ ? [{ type: 'text', text: escapePreviewText(inputPreview.text) }]
: [],
...(rawInput
? { rawInput, contentIsInput: true }
- : action.inputPreview
+ : inputPreview
? { contentIsInput: true }
: {}),
- options: action.options.map((option) => ({
- id: option.id,
- label: option.label,
- ...(OPTION_KINDS[option.id] ? { kind: OPTION_KINDS[option.id] } : {}),
- })),
+ options: (action.kind === 'question' ? [] : action.options).map(
+ (option) => ({
+ id: option.id,
+ label: option.label,
+ ...(OPTION_KINDS[option.id] ? { kind: OPTION_KINDS[option.id] } : {}),
+ }),
+ ),
};
}
diff --git a/packages/web-shell/client/components/managed/managed-question-response.test.ts b/packages/web-shell/client/components/managed/managed-question-response.test.ts
new file mode 100644
index 00000000000..199bf0426dd
--- /dev/null
+++ b/packages/web-shell/client/components/managed/managed-question-response.test.ts
@@ -0,0 +1,79 @@
+import { createHash } from 'node:crypto';
+import { afterEach, expect, it, vi } from 'vitest';
+import {
+ canonicalQuestionAnswers,
+ questionResponseKey,
+} from './managed-question-response';
+import { pendingQuestion } from './managed-question.test-fixtures';
+
+afterEach(() => vi.unstubAllGlobals());
+
+it('keeps the same SHA-256 key on ordinary HTTP without Web Crypto', () => {
+ vi.stubGlobal('crypto', undefined);
+ const action = pendingQuestion();
+ const answers = canonicalQuestionAnswers(action, [
+ { questionId: 'q1', text: 'Custom 中😀 / "quoted"\n' },
+ { questionId: 'q2', optionIds: ['q2o2', 'q2o1'] },
+ ]);
+ const expected = createHash('sha256')
+ .update(
+ JSON.stringify([
+ action.sessionId,
+ action.actionId,
+ action.inputRevision,
+ action.policyRevision,
+ answers,
+ ]),
+ )
+ .digest('hex');
+ expect(questionResponseKey(action, answers)).toBe(`question:${expected}`);
+});
+
+it('canonicalizes by immutable IDs and produces a bounded key independent of selection order', async () => {
+ const action = pendingQuestion();
+ const first = canonicalQuestionAnswers(action, [
+ { questionId: 'q2', optionIds: ['q2o2', 'q2o1'], text: ' my answer ' },
+ { questionId: 'q1', optionIds: ['q1o2'] },
+ ]);
+ const second = canonicalQuestionAnswers(action, [
+ { questionId: 'q1', optionIds: ['q1o2'] },
+ { questionId: 'q2', optionIds: ['q2o1', 'q2o2'], text: ' my answer ' },
+ ]);
+ expect(first).toEqual(second);
+ const key = await questionResponseKey(action, first);
+ expect(key).toMatch(/^question:[0-9a-f]{64}$/);
+ expect(key).not.toContain('my answer');
+ expect(await questionResponseKey(structuredClone(action), second)).toBe(key);
+ expect(
+ await questionResponseKey({ ...action, inputRevision: 2 }, second),
+ ).not.toBe(key);
+ expect(
+ await questionResponseKey(
+ { ...action, sessionId: 'other-session' },
+ second,
+ ),
+ ).not.toBe(key);
+ expect(
+ await questionResponseKey(action, [{ questionId: 'q1', text: 'changed' }]),
+ ).not.toBe(key);
+});
+
+it('retains invalid IDs for server rejection instead of silently discarding answers', () => {
+ expect(
+ canonicalQuestionAnswers(pendingQuestion(), [
+ { questionId: 'q1', optionIds: ['unknown', 'q1o1', 'q1o1'] },
+ { questionId: 'unknown', text: 'not discarded' },
+ ]),
+ ).toEqual([
+ { questionId: 'q1', optionIds: ['q1o1', 'q1o1', 'unknown'] },
+ { questionId: 'unknown', text: 'not discarded' },
+ ]);
+});
+
+it('bounds serialized UTF-8 answers without trimming or truncating them', () => {
+ const answers = [{ questionId: 'q1', text: '界'.repeat(12_000) }];
+ expect(() => canonicalQuestionAnswers(pendingQuestion(), answers)).toThrow(
+ 'exceed 32 KiB',
+ );
+ expect(answers[0].text.length).toBe(12_000);
+});
diff --git a/packages/web-shell/client/components/managed/managed-question-response.ts b/packages/web-shell/client/components/managed/managed-question-response.ts
new file mode 100644
index 00000000000..4b67dd91720
--- /dev/null
+++ b/packages/web-shell/client/components/managed/managed-question-response.ts
@@ -0,0 +1,71 @@
+import { sha256 } from '@noble/hashes/sha256';
+import type {
+ ManagedAgentPendingQuestionAction,
+ ManagedAgentQuestionAnswer,
+} from './managed-agent-provider';
+
+export const MAX_MANAGED_QUESTION_ANSWER_BYTES = 32 * 1024;
+
+export function canonicalQuestionAnswers(
+ action: ManagedAgentPendingQuestionAction,
+ answers: readonly ManagedAgentQuestionAnswer[],
+): ManagedAgentQuestionAnswer[] {
+ const questionOrder = new Map(
+ action.questions.map((question, index) => [question.id, index]),
+ );
+ const ordered = answers.map((answer) => {
+ const question = action.questions.find(
+ (candidate) => candidate.id === answer.questionId,
+ );
+ const optionOrder = new Map(
+ question?.options.map((option, index) => [option.id, index]),
+ );
+ return {
+ questionId: answer.questionId,
+ ...(answer.optionIds === undefined
+ ? {}
+ : {
+ optionIds: [...answer.optionIds].sort(
+ (a, b) =>
+ (optionOrder.get(a) ?? Infinity) -
+ (optionOrder.get(b) ?? Infinity),
+ ),
+ }),
+ ...(answer.text === undefined ? {} : { text: answer.text }),
+ };
+ });
+ ordered.sort(
+ (a, b) =>
+ (questionOrder.get(a.questionId) ?? Infinity) -
+ (questionOrder.get(b.questionId) ?? Infinity),
+ );
+ if (
+ new TextEncoder().encode(JSON.stringify(ordered)).byteLength >
+ MAX_MANAGED_QUESTION_ANSWER_BYTES
+ ) {
+ throw Object.assign(new Error('Question answers exceed 32 KiB'), {
+ code: 'question_answer_too_large',
+ });
+ }
+ return ordered;
+}
+
+export function questionResponseKey(
+ action: ManagedAgentPendingQuestionAction,
+ answers: readonly ManagedAgentQuestionAnswer[],
+): string {
+ const digest = sha256(
+ new TextEncoder().encode(
+ JSON.stringify([
+ action.sessionId,
+ action.actionId,
+ action.inputRevision,
+ action.policyRevision,
+ answers,
+ ]),
+ ),
+ );
+ return `question:${Array.from(digest, (byte) =>
+ byte.toString(16).padStart(2, '0'),
+ ).join('')}`;
+}
diff --git a/packages/web-shell/client/components/managed/managed-question.test-fixtures.ts b/packages/web-shell/client/components/managed/managed-question.test-fixtures.ts
new file mode 100644
index 00000000000..eb414edb891
--- /dev/null
+++ b/packages/web-shell/client/components/managed/managed-question.test-fixtures.ts
@@ -0,0 +1,39 @@
+import type { ManagedAgentPendingQuestionAction } from './managed-agent-provider';
+
+export function pendingQuestion(): ManagedAgentPendingQuestionAction {
+ return {
+ kind: 'question',
+ actionId: 'tool_approval_question',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ functionCallId: 'call-1',
+ toolName: 'ask_user_question',
+ inputRevision: 1,
+ policyRevision: 'hosted-user-question/1',
+ expiresAt: Date.now() + 600_000,
+ questions: [
+ {
+ id: 'q1',
+ prompt: 'Choose the library',
+ required: true,
+ multiple: false,
+ allowText: true,
+ options: [
+ { id: 'q1o1', label: 'Same, label', description: 'First library' },
+ { id: 'q1o2', label: 'Same, label', description: 'Second library' },
+ ],
+ },
+ {
+ id: 'q2',
+ prompt: 'Choose the features',
+ required: true,
+ multiple: true,
+ allowText: true,
+ options: [
+ { id: 'q2o1', label: 'Read, write', description: 'File access' },
+ { id: 'q2o2', label: 'Search', description: 'Code lookup' },
+ ],
+ },
+ ],
+ };
+}
diff --git a/packages/web-shell/client/components/managed/use-managed-actions.test.tsx b/packages/web-shell/client/components/managed/use-managed-actions.test.tsx
index c8fc38068bf..2c9cd1559d0 100644
--- a/packages/web-shell/client/components/managed/use-managed-actions.test.tsx
+++ b/packages/web-shell/client/components/managed/use-managed-actions.test.tsx
@@ -10,6 +10,8 @@ import type {
} from './managed-agent-provider';
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
import { useManagedActions } from './use-managed-actions';
+import { webcrypto } from 'node:crypto';
+import { pendingQuestion } from './managed-question.test-fixtures';
const pending: ManagedAgentPendingAction = {
actionId: 'tool_approval_1',
@@ -48,6 +50,7 @@ describe('useManagedActions', () => {
act(() => root?.unmount());
root = undefined;
vi.useRealTimers();
+ vi.unstubAllGlobals();
});
function mount(
@@ -122,6 +125,102 @@ describe('useManagedActions', () => {
expect(hook.latest?.answerError).toBeUndefined();
});
+ it('keeps a question visible while answering and reuses the canonical response key on retry and reload', async () => {
+ vi.stubGlobal('crypto', webcrypto);
+ const question = pendingQuestion();
+ const listPending = vi.fn().mockResolvedValue([question]);
+ let reject!: (error: Error) => void;
+ const respond = vi
+ .fn()
+ .mockImplementationOnce(
+ () => new Promise((_resolve, fail) => (reject = fail)),
+ )
+ .mockRejectedValue(new Error('unconfirmed'));
+ const provider = {
+ actions: { listPending, respond },
+ } as unknown as ManagedAgentProvider;
+ const hook = mount(provider, { enabled: true, events: [] });
+ await vi.waitFor(() => expect(hook.latest?.action).toEqual(question));
+ const response = {
+ kind: 'question' as const,
+ answers: [
+ {
+ questionId: 'q2',
+ optionIds: ['q2o2', 'q2o1'],
+ text: 'custom, value',
+ },
+ { questionId: 'q1', optionIds: ['q1o2'] },
+ ],
+ };
+ let first!: Promise;
+ await act(async () => {
+ first = hook.latest!.respond(question.actionId, response);
+ await hook.latest!.respond(question.actionId, response);
+ });
+ await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
+ expect(hook.latest?.action).toEqual(question);
+ const key = respond.mock.calls[0][2].idempotencyKey;
+ expect(key).toMatch(/^question:[0-9a-f]{64}$/);
+ await act(async () => {
+ reject(new Error('unconfirmed'));
+ await expect(first).rejects.toThrow('unconfirmed');
+ });
+ await act(async () => {
+ await expect(
+ hook.latest!.respond(question.actionId, {
+ ...response,
+ answers: [...response.answers].reverse().map((answer) => ({
+ ...answer,
+ optionIds: [...answer.optionIds].reverse(),
+ })),
+ }),
+ ).rejects.toThrow('unconfirmed');
+ });
+ expect(respond.mock.calls[1][2].idempotencyKey).toBe(key);
+ act(() => root!.unmount());
+ root = undefined;
+ const reloaded = mount(provider, { enabled: true, events: [] });
+ await vi.waitFor(() => expect(reloaded.latest?.action).toEqual(question));
+ respond.mockResolvedValueOnce(undefined);
+ await act(async () =>
+ reloaded.latest!.respond(question.actionId, response),
+ );
+ expect(respond.mock.calls[2][2].idempotencyKey).toBe(key);
+ expect(reloaded.latest?.action).toBeUndefined();
+ });
+
+ it('reconciles a question answered by another client and refreshes after a stream gap', async () => {
+ vi.stubGlobal('crypto', webcrypto);
+ const question = pendingQuestion();
+ const listPending = vi
+ .fn()
+ .mockResolvedValueOnce([question])
+ .mockResolvedValueOnce([])
+ .mockResolvedValue([question]);
+ const respond = vi
+ .fn()
+ .mockRejectedValue({ code: 'action_already_resolved' });
+ const hook = mount(
+ { actions: { listPending, respond } } as unknown as ManagedAgentProvider,
+ { enabled: true, events: [] },
+ );
+ await vi.waitFor(() => expect(hook.latest?.action).toEqual(question));
+ await act(async () =>
+ hook.latest!.respond(question.actionId, {
+ kind: 'question',
+ answers: [
+ { questionId: 'q1', text: 'custom' },
+ { questionId: 'q2', optionIds: ['q2o1'] },
+ ],
+ }),
+ );
+ await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
+ expect(hook.latest?.action).toBeUndefined();
+ hook.rerender({ events: [gap(9)] });
+ await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(3));
+ expect(hook.latest?.answerError).toBeUndefined();
+ });
+
it('drops an aggregate response after changing credentials even if the provider ignores abort', async () => {
let resolve!: (actions: ManagedAgentPendingAction[]) => void;
const listPending = vi
diff --git a/packages/web-shell/client/components/managed/use-managed-actions.ts b/packages/web-shell/client/components/managed/use-managed-actions.ts
index c1afe6ba22d..ba6ddc046b8 100644
--- a/packages/web-shell/client/components/managed/use-managed-actions.ts
+++ b/packages/web-shell/client/components/managed/use-managed-actions.ts
@@ -1,10 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ManagedAgentPendingAction,
+ ManagedAgentActionResponse,
ManagedAgentProvider,
ManagedAgentSessionEvent,
} from './managed-agent-provider';
import { isNonRetryableClientError } from './managed-request-error';
+import {
+ canonicalQuestionAnswers,
+ questionResponseKey,
+} from './managed-question-response';
// Re-reads shortly after the earliest expiry so an unanswered approval leaves
// the page once the Harness has ended it.
@@ -63,7 +68,10 @@ export interface ManagedActionsState {
* re-probes instead of treating one 403 as permanent for the mount.
*/
respondForbidden: boolean;
- respond(actionId: string, optionId: string): Promise;
+ respond(
+ actionId: string,
+ response: ManagedAgentActionResponse,
+ ): Promise;
/** Reads the pending approvals again now. */
retry(): void;
}
@@ -122,6 +130,7 @@ export function useManagedActions(
// read that still lists one shows it again, so a wrong report cannot hide
// an approvable Action.
const endedAnswers = useRef(new Set());
+ const inFlight = useRef(new Map());
const trigger = useMemo(() => {
let last = 0;
for (const event of events) {
@@ -259,25 +268,42 @@ export function useManagedActions(
current.current = { owner, actions };
const respond = useCallback(
- async (actionId: string, optionId: string) => {
+ async (actionId: string, response: ManagedAgentActionResponse) => {
const target = actions.find(
(entry) =>
entry.actionId === actionId && !answered.has(actionKey(entry)),
);
if (!target || !reader) return;
const key = actionKey(target);
+ if (inFlight.current.get(key) === owner) return;
+ inFlight.current.set(key, owner);
const isPending = () =>
current.current.owner === owner &&
current.current.actions.some((entry) => actionKey(entry) === key);
- setAnswered((current) => new Set(current).add(key));
+ if (target.kind !== 'question')
+ setAnswered((current) => new Set(current).add(key));
try {
- // One key per Action and option: a retried click replays the same
- // durable operation instead of answering twice.
- await reader.respond(target, optionId, {
+ if ((target.kind === 'question') !== (typeof response !== 'string')) {
+ throw new Error('Action response kind does not match the request');
+ }
+ const submitted =
+ target.kind === 'question' && typeof response !== 'string'
+ ? {
+ kind: 'question' as const,
+ answers: canonicalQuestionAnswers(target, response.answers),
+ }
+ : response;
+ const idempotencyKey =
+ target.kind === 'question' && typeof submitted !== 'string'
+ ? await questionResponseKey(target, submitted.answers)
+ : `${target.actionId}:${submitted}`;
+ if (!isPending()) return;
+ await reader.respond(target, submitted, {
clientId,
- idempotencyKey: `${target.actionId}:${optionId}`,
+ idempotencyKey,
});
if (!isPending()) return;
+ setAnswered((current) => new Set(current).add(key));
if (answerFailure.current === key) {
setAnswerError(undefined);
answerFailure.current = undefined;
@@ -307,6 +333,7 @@ export function useManagedActions(
// No retry can succeed, so keep the card hidden and read the list
// again instead of offering an answer the service will refuse.
endedAnswers.current.add(key);
+ setAnswered((current) => new Set(current).add(key));
if (answerFailure.current === key) {
answerFailure.current = undefined;
setAnswerError(undefined);
@@ -322,6 +349,8 @@ export function useManagedActions(
setAnswerError(failure);
answerFailure.current = key;
throw failure;
+ } finally {
+ if (inFlight.current.get(key) === owner) inFlight.current.delete(key);
}
},
[actions, reader, clientId, owner, credentials, answered],
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx
index 282344d8164..f2cf3bcc744 100644
--- a/packages/web-shell/client/i18n.tsx
+++ b/packages/web-shell/client/i18n.tsx
@@ -384,11 +384,21 @@ const EN: Messages = {
'managed.discarded': 'Request discarded. The draft is back in the composer.',
'managed.newRequired': 'Start a new task to send another message.',
'managed.truncated': '[Details truncated]',
+ 'managed.question.title': 'Answer the questions',
+ 'managed.question.other': 'Other answer',
+ 'managed.question.expired':
+ 'This question has expired. Refresh to check the session.',
+ 'managed.question.tooLarge':
+ 'Keep each text answer within 8192 characters and all answers within 32 KiB.',
+ 'managed.question.failed':
+ 'The answer could not be confirmed. Retry the same answers or refresh to check their status.',
+ 'managed.question.forbidden':
+ "Only the Session's owner or a Workspace operator can answer these questions.",
'managed.approval.failed':
'The approval answer could not be confirmed. Retry the same option or refresh to check its status.',
- 'managed.approval.loadFailed': 'Pending approvals could not be loaded.',
- 'managed.approval.refreshFailed': 'Pending approvals could not be refreshed.',
- 'managed.approval.retry': 'Retry loading approvals',
+ 'managed.approval.loadFailed': 'Pending requests could not be loaded.',
+ 'managed.approval.refreshFailed': 'Pending requests could not be refreshed.',
+ 'managed.approval.retry': 'Retry loading requests',
'managed.approval.forbidden':
"Only the Session's owner or a Workspace operator can answer this approval.",
'managed.approval.argumentsUnavailable':
@@ -5300,11 +5310,20 @@ const ZH: Messages = {
'managed.discarded': '已丢弃本次请求。草稿已放回输入框。',
'managed.newRequired': '请新建任务后发送消息。',
'managed.truncated': '[详情已截断]',
+ 'managed.question.title': '请回答以下问题',
+ 'managed.question.other': '其他答案',
+ 'managed.question.expired': '此问题已过期,请刷新查看会话状态。',
+ 'managed.question.tooLarge':
+ '每个文本答案最多 8192 个字符,全部答案最多 32 KiB。',
+ 'managed.question.failed':
+ '尚未确认回答结果,请重试相同的答案或刷新查看状态。',
+ 'managed.question.forbidden':
+ '只有会话所有者或工作区操作员可以回答这些问题。',
'managed.approval.failed':
'无法确认审批回答的结果。请重试同一选项,或刷新以查看状态。',
- 'managed.approval.loadFailed': '待审批请求加载失败。',
- 'managed.approval.refreshFailed': '待审批请求刷新失败。',
- 'managed.approval.retry': '重新读取审批',
+ 'managed.approval.loadFailed': '待处理请求加载失败。',
+ 'managed.approval.refreshFailed': '待处理请求刷新失败。',
+ 'managed.approval.retry': '重新读取请求',
'managed.approval.forbidden':
'只有此会话的拥有者或所属 Workspace 的操作者可以回答这项审批。',
'managed.approval.argumentsUnavailable': '此项审批的工具参数暂不可见。',
diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx
index 615074f28e6..079b4552b72 100644
--- a/packages/web-shell/client/index.tsx
+++ b/packages/web-shell/client/index.tsx
@@ -51,6 +51,11 @@ export type {
export {
type ManagedAgentCommandOptions,
type ManagedAgentPendingAction,
+ type ManagedAgentPendingPermissionAction,
+ type ManagedAgentPendingQuestionAction,
+ type ManagedAgentQuestionAnswer,
+ type ManagedAgentQuestionResponse,
+ type ManagedAgentActionResponse,
type ManagedAgentProvider,
type ManagedAgentRequestOptions,
type ManagedAgentRuntimeState,
diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json
index fc3235979cf..ee77daeb59c 100644
--- a/packages/web-shell/package.json
+++ b/packages/web-shell/package.json
@@ -66,6 +66,7 @@
"@datafe-open/markdown-chart-echarts": "^0.1.12",
"@datafe-open/markdown-chart-react": "^0.1.12",
"@modelcontextprotocol/ext-apps": "^1.7.5",
+ "@noble/hashes": "1.8.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@types/hast": "^3.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2da50d6ed86..943297c2b1e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1621,6 +1621,9 @@ importers:
'@modelcontextprotocol/ext-apps':
specifier: ^1.7.5
version: 1.7.5(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)
+ '@noble/hashes':
+ specifier: 1.8.0
+ version: 1.8.0
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)