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> answers = new ArrayList<>(); + for (JsonNode answer : response.get("answers")) { + Map item = new LinkedHashMap<>(); + item.put("questionId", answer.path("questionId").textValue()); + if (answer.has("optionIds")) { + List options = new ArrayList<>(); + answer.get("optionIds").forEach(option -> options.add(option.textValue())); + item.put("optionIds", options); + } + if (answer.has("text")) { + item.put("text", answer.get("text").textValue()); + } + answers.add(item); + } + client().resolveQuestion(ref, actionId, answers, response.path("inputRevision").asLong(), + response.path("policyRevision").asText()); + } else { + client().resolveAction( ref, actionId, response.path("optionId").asText(), response.path("inputRevision").asLong(), response.path("policyRevision").asText()); + } } @Override diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecords.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecords.java index d93e1f462b0..2107635d784 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecords.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecords.java @@ -60,7 +60,7 @@ public final class ManagedHostedRecoveryRecords { } JsonNode body = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(stored.bytes(), StandardCharsets.UTF_8)); require(body != null && stored.schemaVersion() == 1, "Invalid Hosted recovery resource."); - if ("managed-action-options".equals(stored.kind()) && body.path("v").asInt() != 3) { + if ("managed-action-options".equals(stored.kind()) && body.path("v").asInt() != 3 && body.path("v").asInt() != 4) { return; } if (KINDS.contains(stored.kind())) { diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestions.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestions.java new file mode 100644 index 00000000000..4a67cd68c2b --- /dev/null +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestions.java @@ -0,0 +1,184 @@ +package com.alibaba.qwen.code.managedagent.contracts.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.event.ManagedEventEnvelopes; +import com.alibaba.qwen.code.runtimebroker.BrokerValues; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.codec.digest.DigestUtils; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +public final class ManagedUserQuestions { + public static final String POLICY = "hosted-user-question/1"; + public static final int MAX_INPUT_BYTES = 16 * 1024; + public static final int MAX_ANSWERS_BYTES = 32 * 1024; + private static final JsonNodeFactory JSON = JsonNodeFactory.instance; + private static final Pattern BLANK = Pattern.compile( + "[\\s\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*"); + + private ManagedUserQuestions() { } + + public static boolean isQuestion(JsonNode options) { + return options.path("v").isIntegralNumber() && options.path("v").asLong() == 4; + } + + public static ArrayNode questionsFromInput(JsonNode input) { + closed(input, Set.of("questions"), Set.of("metadata")); + if (input.has("metadata")) { + closed(input.get("metadata"), Set.of(), Set.of("source")); + if (input.get("metadata").has("source")) { + text(input.path("metadata").get("source"), Integer.MAX_VALUE, false); + } + } + JsonNode source = input.get("questions"); + require(source.isArray() && source.size() >= 1 && source.size() <= 4); + ArrayNode result = JSON.arrayNode(); + for (int index = 0; index < source.size(); index++) { + JsonNode question = source.get(index); + closed(question, Set.of("question", "header", "options"), Set.of("multiSelect")); + text(question.get("header"), Integer.MAX_VALUE, true); + String prompt = text(question.get("question"), 2048, true); + require(!question.has("multiSelect") || question.get("multiSelect").isBoolean()); + JsonNode choices = question.get("options"); + require(choices.isArray() && choices.size() >= 2 && choices.size() <= 4); + String id = "q" + (index + 1); + ObjectNode item = result.addObject().put("id", id).put("prompt", prompt) + .put("required", true).put("multiple", question.path("multiSelect").asBoolean()) + .put("allowText", true); + ArrayNode options = item.putArray("options"); + for (int option = 0; option < choices.size(); option++) { + JsonNode choice = choices.get(option); + closed(choice, Set.of("label", "description"), Set.of()); + options.addObject().put("id", id + "o" + (option + 1)) + .put("label", text(choice.get("label"), 256, true)) + .put("description", text(choice.get("description"), 1024, true)); + } + } + require(ManagedEventEnvelopes.canonicalJson(input).getBytes(StandardCharsets.UTF_8).length <= MAX_INPUT_BYTES); + return result; + } + + public static void validateQuestions(JsonNode questions) { + require(questions != null && questions.isArray() && questions.size() >= 1 && questions.size() <= 4); + for (int index = 0; index < questions.size(); index++) { + JsonNode question = questions.get(index); + closed(question, Set.of("id", "prompt", "required", "multiple", "allowText", "options"), Set.of()); + String id = "q" + (index + 1); + require(id.equals(text(question.get("id"), 128, true)) + && question.path("required").isBoolean() && question.path("required").booleanValue() + && question.path("allowText").isBoolean() && question.path("allowText").booleanValue() + && question.path("multiple").isBoolean()); + text(question.get("prompt"), 2048, true); + JsonNode options = question.get("options"); + require(options.isArray() && options.size() >= 2 && options.size() <= 4); + for (int option = 0; option < options.size(); option++) { + JsonNode choice = options.get(option); + closed(choice, Set.of("id", "label", "description"), Set.of()); + require((id + "o" + (option + 1)).equals(text(choice.get("id"), 128, true))); + text(choice.get("label"), 256, true); + text(choice.get("description"), 1024, true); + } + } + } + + public static ArrayNode canonicalAnswers(JsonNode questions, JsonNode answers, boolean publicNames) { + validateQuestions(questions); + require(answers != null && answers.isArray() && answers.size() == questions.size()); + String questionKey = publicNames ? "question_id" : "questionId"; + String optionKey = publicNames ? "option_ids" : "optionIds"; + Map byQuestion = new HashMap<>(); + for (JsonNode answer : answers) { + closed(answer, Set.of(questionKey), Set.of(optionKey, "text")); + String id = text(answer.get(questionKey), 128, true); + require(byQuestion.putIfAbsent(id, answer) == null); + } + ArrayNode result = JSON.arrayNode(); + for (JsonNode question : questions) { + String id = question.get("id").textValue(); + JsonNode answer = byQuestion.remove(id); + require(answer != null); + Set selected = new HashSet<>(); + if (answer.has(optionKey)) { + JsonNode options = answer.get(optionKey); + require(options.isArray() && !options.isEmpty() && options.size() <= 4); + for (JsonNode option : options) { + require(selected.add(text(option, 128, true))); + } + } + String custom = answer.has("text") ? text(answer.get("text"), 8192, true) : null; + require(!selected.isEmpty() || custom != null); + require(question.get("multiple").booleanValue() || selected.size() + (custom == null ? 0 : 1) == 1); + ObjectNode canonical = result.addObject().put("questionId", id); + if (!selected.isEmpty()) { + ArrayNode options = canonical.putArray("optionIds"); + for (JsonNode option : question.get("options")) { + String optionId = option.get("id").textValue(); + if (selected.remove(optionId)) { + options.add(optionId); + } + } + require(selected.isEmpty()); + } + if (custom != null) { + canonical.put("text", custom); + } + } + require(byQuestion.isEmpty() && answersJson(result).getBytes(StandardCharsets.UTF_8).length <= MAX_ANSWERS_BYTES); + return result; + } + + public static String decisionDigest(JsonNode response) { + return DigestUtils.sha256Hex(decisionJson(response).getBytes(StandardCharsets.UTF_8)); + } + + public static String decisionJson(JsonNode response) { + return "{\"v\":2,\"answers\":" + answersJson(response.path("answers")) + + ",\"inputRevision\":" + response.path("inputRevision").longValue() + + ",\"policyRevision\":" + ManagedEventEnvelopes.canonicalJson(response.path("policyRevision")) + "}"; + } + + private static String answersJson(JsonNode answers) { + StringBuilder result = new StringBuilder("["); + for (JsonNode answer : answers) { + if (result.length() > 1) { + result.append(','); + } + result.append("{\"questionId\":").append(ManagedEventEnvelopes.canonicalJson(answer.get("questionId"))); + if (answer.has("optionIds")) { + result.append(",\"optionIds\":").append(ManagedEventEnvelopes.canonicalJson(answer.get("optionIds"))); + } + if (answer.has("text")) { + result.append(",\"text\":").append(ManagedEventEnvelopes.canonicalJson(answer.get("text"))); + } + result.append('}'); + } + return result.append(']').toString(); + } + + private static String text(JsonNode value, int maximum, boolean required) { + require(value != null && value.isTextual()); + String result = BrokerValues.requireWellFormed(value.textValue(), "Question text"); + require(result.length() <= maximum && (!required || !BLANK.matcher(result).matches())); + return result; + } + + private static void closed(JsonNode value, Set required, Set optional) { + require(value != null && value.isObject() && value.size() >= required.size() + && value.size() <= required.size() + optional.size()); + require(required.stream().allMatch(value::has)); + value.fieldNames().forEachRemaining(field -> require(required.contains(field) || optional.contains(field))); + } + + private static void require(boolean valid) { + if (!valid) { + throw new IllegalArgumentException("The structured question or answer is invalid or exceeds its size limit."); + } + } +} diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java index be6fced5dda..4b4723182fd 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java @@ -932,28 +932,77 @@ public final class ApiModels { Integer limit) { } + @JsonDeserialize(using = PermissionResponseDeserializer.class) public record PermissionResponse( @NotBlank String kind, @JsonProperty("input_revision") JsonNode inputRevision, @JsonProperty("policy_revision") @NotBlank @Size(max = 128) String policyRevision, - @JsonProperty("option_id") @NotBlank @Size(max = 128) String optionId) { + @JsonProperty("option_id") @Size(max = 128) String optionId, + JsonNode answers) { + public PermissionResponse(String kind, JsonNode inputRevision, String policyRevision, String optionId) { + this(kind, inputRevision, policyRevision, optionId, null); + } @JsonAnySetter public void rejectUnknown(String name, JsonNode value) { throw new IllegalArgumentException("Unknown Action request field: " + name); } } + @JsonDeserialize(using = WebShellPermissionResponseDeserializer.class) public record WebShellPermissionResponse( @NotBlank String kind, JsonNode inputRevision, @NotBlank @Size(max = 128) String policyRevision, - @NotBlank @Size(max = 128) String optionId) { + @Size(max = 128) String optionId, + JsonNode answers) { + public WebShellPermissionResponse(String kind, JsonNode inputRevision, String policyRevision, String optionId) { + this(kind, inputRevision, policyRevision, optionId, null); + } @JsonAnySetter public void rejectUnknown(String name, JsonNode value) { throw new IllegalArgumentException("Unknown Action request field: " + name); } } + public static final class PermissionResponseDeserializer extends StdDeserializer { + public PermissionResponseDeserializer() { super(PermissionResponse.class); } + + @Override + public PermissionResponse deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode node = actionResponse(parser, context, false); + return new PermissionResponse(node.get("kind").textValue(), node.get("input_revision"), + node.get("policy_revision").textValue(), node.path("option_id").textValue(), node.get("answers")); + } + } + + public static final class WebShellPermissionResponseDeserializer extends StdDeserializer { + public WebShellPermissionResponseDeserializer() { super(WebShellPermissionResponse.class); } + + @Override + public WebShellPermissionResponse deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode node = actionResponse(parser, context, true); + return new WebShellPermissionResponse(node.get("kind").textValue(), node.get("inputRevision"), + node.get("policyRevision").textValue(), node.path("optionId").textValue(), node.get("answers")); + } + } + + private static JsonNode actionResponse(JsonParser parser, DeserializationContext context, boolean web) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + String revision = web ? "inputRevision" : "input_revision"; + String policy = web ? "policyRevision" : "policy_revision"; + String option = web ? "optionId" : "option_id"; + String kind = node.path("kind").textValue(); + String value = "question".equals(kind) ? "answers" : option; + Set fields = Set.of("kind", revision, policy, value); + if (!node.isObject() || node.size() != fields.size() || !fields.stream().allMatch(node::has) + || !("permission".equals(kind) || "question".equals(kind)) + || !node.path(policy).isTextual() + || ("permission".equals(kind) ? !node.path(option).isTextual() : !node.path("answers").isArray())) { + throw context.weirdStringException(kind, PermissionResponse.class, "Invalid Action response fields"); + } + return node; + } + public record WebShellActionQueryRequest( @NotBlank String sessionId, @Size(max = 512) String cursor, Integer limit, Boolean includeChildren) { public WebShellActionQueryRequest(String sessionId, String cursor, Integer limit) { diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/repository/ManagedActionStore.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/repository/ManagedActionStore.java index 893febd671b..647d7351ecc 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/repository/ManagedActionStore.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/repository/ManagedActionStore.java @@ -71,6 +71,9 @@ public interface ManagedActionStore { } public static String decisionDigest(JsonNode response) { + if (response.has("answers")) { + return com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedUserQuestions.decisionDigest(response); + } Map decision = new LinkedHashMap<>(); decision.put("v", 1); decision.put("optionId", response.path("optionId").asText()); diff --git a/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json index 15f89f3509f..be50998e9a8 100644 --- a/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Qwen Managed Agent Public and WebShell API", - "version": "1.99.0", + "version": "1.100.0", "description": "Canonical first-release contract for the public Managed Agent resources and the WebShell adapter. Implemented routes and fields describe the current matching Server, Broker, Harness and WebShell build. Partial entries have remaining implementation or contract-test work; planned entries are not current server capabilities and must be excluded from production SDKs. Turn submission and cancellation always return the durable operation identifier and its persisted admission and delivery state. Session-list cursors are bound to the normalized archive state and title query. Earlier development API shapes, checkpoints and database catalogs are unsupported; initialize fresh state for this build. Runtime feature capabilities and deployment policy still govern optional execution modes. Public conversation history reads directly from mutable message projections; persisted conversation snapshots and fixed-content paging are no longer provided." }, "servers": [ @@ -1768,7 +1768,7 @@ "description": "Aggregate pending Actions from this public root Session and its direct children; responses use each Action owning Session." } ], - "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." } }, "/v1/agents/sessions/{sessionId}/actions/{actionId}": { @@ -1812,7 +1812,7 @@ "$ref": "#/components/responses/Forbidden" } }, - "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." } }, "/v1/agents/sessions/{sessionId}/actions/{actionId}/responses": { @@ -1876,7 +1876,7 @@ } } }, - "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." } }, "/v1/agents/sessions/{sessionId}/close": { @@ -2065,7 +2065,7 @@ } } }, - "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." } }, "/api/agent/web-shell/v1/actions/get": { @@ -2114,7 +2114,7 @@ } } }, - "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." } }, "/api/agent/web-shell/v1/actions/respond": { @@ -2165,7 +2165,7 @@ } } }, - "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." } }, "/api/agent/web-shell/v1/sessions/close": { @@ -10419,13 +10419,14 @@ }, "options": { "type": "array", - "maxItems": 50, + "minItems": 2, + "maxItems": 4, "items": { "$ref": "#/components/schemas/ActionOption" } } }, - "x-qwen-implementation-status": "planned" + "x-qwen-implementation-status": "implemented" }, "ActionSource": { "type": "object", @@ -10563,7 +10564,9 @@ "policy_revision", "created_at", "expires_at", - "questions" + "questions", + "function_call_id", + "tool_name" ], "properties": { "id": { @@ -10607,16 +10610,31 @@ "minLength": 1, "maxLength": 128 }, + "turn_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "function_call_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tool_name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, "questions": { "type": "array", "minItems": 1, - "maxItems": 20, + "maxItems": 4, "items": { "$ref": "#/components/schemas/ActionQuestion" } } }, - "x-qwen-implementation-status": "planned", + "x-qwen-implementation-status": "implemented", "allOf": [ { "if": { @@ -10678,7 +10696,7 @@ "option_ids": { "type": "array", "minItems": 1, - "maxItems": 50, + "maxItems": 4, "uniqueItems": true, "items": { "type": "string", @@ -10692,13 +10710,17 @@ "maxLength": 8192 } }, - "x-qwen-implementation-status": "planned", - "anyOf": [ + "x-qwen-implementation-status": "implemented", + "allOf": [ { - "required": ["option_ids"] - }, - { - "required": ["text"] + "anyOf": [ + { + "required": ["option_ids"] + }, + { + "required": ["text"] + } + ] } ] }, @@ -10748,13 +10770,13 @@ "answers": { "type": "array", "minItems": 1, - "maxItems": 20, + "maxItems": 4, "items": { "$ref": "#/components/schemas/QuestionAnswer" } } }, - "x-qwen-implementation-status": "planned" + "x-qwen-implementation-status": "implemented" }, "ActionResponseRequest": { "oneOf": [ @@ -11132,7 +11154,7 @@ "optionIds": { "type": "array", "minItems": 1, - "maxItems": 50, + "maxItems": 4, "uniqueItems": true, "items": { "type": "string", @@ -11146,13 +11168,17 @@ "maxLength": 8192 } }, - "x-qwen-implementation-status": "planned", - "anyOf": [ + "x-qwen-implementation-status": "implemented", + "allOf": [ { - "required": ["optionIds"] - }, - { - "required": ["text"] + "anyOf": [ + { + "required": ["optionIds"] + }, + { + "required": ["text"] + } + ] } ] }, @@ -11189,13 +11215,14 @@ }, "options": { "type": "array", - "maxItems": 50, + "minItems": 2, + "maxItems": 4, "items": { "$ref": "#/components/schemas/WebShellActionOption" } } }, - "x-qwen-implementation-status": "planned" + "x-qwen-implementation-status": "implemented" }, "WebShellCommandOperation": { "type": "object", @@ -11452,7 +11479,9 @@ "policyRevision", "createdAt", "expiresAt", - "questions" + "questions", + "functionCallId", + "toolName" ], "properties": { "actionId": { @@ -11496,16 +11525,31 @@ "minLength": 1, "maxLength": 128 }, + "turnId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "functionCallId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "toolName": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, "questions": { "type": "array", "minItems": 1, - "maxItems": 20, + "maxItems": 4, "items": { "$ref": "#/components/schemas/WebShellActionQuestion" } } }, - "x-qwen-implementation-status": "planned", + "x-qwen-implementation-status": "implemented", "allOf": [ { "if": { @@ -11745,13 +11789,13 @@ "answers": { "type": "array", "minItems": 1, - "maxItems": 20, + "maxItems": 4, "items": { "$ref": "#/components/schemas/WebShellQuestionAnswer" } } }, - "x-qwen-implementation-status": "planned" + "x-qwen-implementation-status": "implemented" }, "WebShellOperation": { "oneOf": [ @@ -11789,7 +11833,7 @@ "includeChildren": { "type": "boolean", "default": false, - "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." } } }, diff --git a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java index 143a1853cea..e274087cc03 100644 --- a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java +++ b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedActionService.java @@ -1,6 +1,7 @@ package com.alibaba.qwen.code.managedagent.session; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedUserQuestions; import com.alibaba.qwen.code.managedagent.contracts.persistence.ToolPublicationContract; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.model.ApiModels.PublicActionList; @@ -192,22 +193,42 @@ public class ManagedActionService { JsonNode revision, String policy, String option) { + return respond(tenant, actor, session, id, key, kind, revision, policy, option, null, false); + } + + public OperationAdmission respond( + String tenant, String actor, String session, String id, String key, + String kind, JsonNode revision, String policy, String option, JsonNode answers, boolean publicNames) { sessions.requireReadableSession(tenant, actor, session); ManagedAgentService.validateIdempotencyKey(key); - if (!"permission".equals(kind) + if (!("permission".equals(kind) || "question".equals(kind)) || revision == null || !revision.isIntegralNumber() || !revision.canConvertToLong()) { throw new ManagedAgentException( 400, "invalid_action_response", - "Only permission responses are supported."); + "The Action response kind or revision is invalid."); } - ObjectNode body = - json.createObjectNode() - .put("optionId", option) - .put("inputRevision", revision.longValue()) - .put("policyRevision", policy); + ObjectNode body = json.createObjectNode(); + if ("question".equals(kind)) { + Action action = actions.find(tenant, session, id).orElseThrow(() -> + new ManagedAgentException(404, "action_not_found", "The Action was not found.")); + try { + if (option != null || !ManagedUserQuestions.isQuestion(action.options())) { + throw new IllegalArgumentException(); + } + body.set("answers", ManagedUserQuestions.canonicalAnswers(action.options().get("questions"), answers, publicNames)); + } catch (IllegalArgumentException error) { + throw new ManagedAgentException(400, "invalid_action_response", "The answer does not match the original question."); + } + } else { + if (answers != null) { + throw new ManagedAgentException(400, "invalid_action_response", "Permission responses cannot contain answers."); + } + body.put("optionId", option); + } + body.put("inputRevision", revision.longValue()).put("policyRevision", policy); OperationAdmission admitted = actions.admit( tenant, @@ -313,7 +334,7 @@ public class ManagedActionService { json.createObjectNode() .put(web ? "actionId" : "id", action.id()) .put(web ? "sessionId" : "session_id", session) - .put("kind", "permission") + .put("kind", ManagedUserQuestions.isQuestion(options) ? "question" : "permission") .put("state", action.state()); result.set( "source", @@ -333,7 +354,18 @@ public class ManagedActionService { } actions.publicTurnId(tenant, session, options.path("turnId").asText()) .ifPresent(turn -> result.put(web ? "turnId" : "turn_id", turn)); - result.set("options", options.path("options")); + if (ManagedUserQuestions.isQuestion(options)) { + JsonNode questions = options.path("questions").deepCopy(); + if (!web) { + for (JsonNode question : questions) { + ObjectNode item = (ObjectNode) question; + item.set("allow_text", item.remove("allowText")); + } + } + result.set("questions", questions); + } else { + result.set("options", options.path("options")); + } JsonNode preview = inputPreview(tenant, session, action, web); if (preview != null) { result.set(web ? "inputPreview" : "input_preview", preview); diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java index 48d637dcc62..2c64e185f5d 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java @@ -90,7 +90,7 @@ final class JournalCommit { return false; } JsonNode body = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(resource.bytes(), StandardCharsets.UTF_8)); - return body != null && body.path("v").asInt() == 3; + return body != null && (body.path("v").asInt() == 3 || body.path("v").asInt() == 4); }); if (promotes) { readerVersion.requireReadable(2); diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionJournalValidation.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionJournalValidation.java new file mode 100644 index 00000000000..1f9ddd8b9fa --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionJournalValidation.java @@ -0,0 +1,103 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedUserQuestions; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.fasterxml.jackson.databind.JsonNode; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; + +final class ManagedActionJournalValidation { + private ManagedActionJournalValidation() { } + + static void validateQuestionResources(String sessionId, JsonNode options, Function resources) { + require("ask_user_question".equals(options.path("toolName").asText()) + && options.path("requiresHumanDecision").isBoolean() && options.path("requiresHumanDecision").booleanValue()); + JsonNode input = read(new String(resource(options.path("inputRef"), "managed-tool-input", resources).bytes(), StandardCharsets.UTF_8)); + closed(input, "harnessSessionId", "runtimeSessionId", "payloadJson"); + require(sessionId.equals(input.path("harnessSessionId").asText()) && text(input.path("payloadJson"))); + JsonNode payload = read(input.path("payloadJson").textValue()); + closed(payload, "toolName", "input"); + require("ask_user_question".equals(payload.path("toolName").asText())); + try { + require(ManagedUserQuestions.questionsFromInput(payload.get("input")).equals(options.path("questions"))); + } catch (IllegalArgumentException error) { + require(false); + } + JsonNode continuation = read(new String(resource(options.path("continuationRef"), "hosted-approval-continuation", resources).bytes(), StandardCharsets.UTF_8)); + JsonNode call = continuation.path("calls").path(0); + require(continuation.path("calls").size() == 1 && continuation.path("approvalOrdinal").asInt(-1) == 0 + && "approval".equals(continuation.path("stage").asText()) + && continuation.path("actionId").equals(options.path("requestId")) + && continuation.path("promptId").equals(options.path("turnId")) + && input.path("runtimeSessionId").equals(continuation.path("runtime").path("runtimeSessionId")) + && call.path("inputRef").equals(options.path("inputRef")) + && call.path("actionId").equals(options.path("requestId")) + && call.path("call").path("callId").equals(options.path("functionCallId")) + && "ask_user_question".equals(call.path("call").path("name").asText())); + } + + static boolean validAnswers(JsonNode options, JsonNode answers) { + try { + return ManagedUserQuestions.canonicalAnswers(options.path("questions"), answers, false).equals(answers); + } catch (IllegalArgumentException error) { + return false; + } + } + + static StoredResource resource( + JsonNode ref, String kind, Function resources) { + closed(ref, "resourceId", "kind", "schemaVersion", "byteLength", "digest"); + require( + text(ref.path("resourceId")) + && kind.equals(ref.path("kind").asText()) + && safeNumber(ref.path("schemaVersion")) + && ref.path("schemaVersion").asLong() == 1 + && safeNumber(ref.path("byteLength")) + && ref.path("digest").asText().matches("[0-9a-f]{64}")); + StoredResource resource = resources.apply(ref.path("resourceId").asText()); + require( + resource != null + && kind.equals(resource.kind()) + && resource.schemaVersion() == 1 + && resource.byteLength() == ref.path("byteLength").asLong() + && resource.digest().equals(ref.path("digest").asText())); + return resource; + } + + static void closed(JsonNode value, String... fields) { + require(value.isObject()); + Set actual = new HashSet<>(); + value.fieldNames().forEachRemaining(actual::add); + require(actual.equals(Set.of(fields))); + } + + static boolean safeNumber(JsonNode value) { + return value.isIntegralNumber() + && value.canConvertToLong() + && value.asLong() >= 0 + && value.asLong() <= 9007199254740991L; + } + + static boolean text(JsonNode value) { + return value.isTextual() && !value.asText().isBlank(); + } + + static JsonNode read(String value) { + JsonNode result = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(value); + require(result != null); + return result; + } + + static void require(boolean valid) { + if (!valid) { + throw new ManagedAgentException( + 400, + "managed_session_action_rejected", + "The Action journal record is invalid."); + } + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java index ad360c6b933..c5a53057513 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedActionStore.java @@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.persistence; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedHostedRecoveryRecords; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedUserQuestions; import com.alibaba.qwen.code.managedagent.contracts.session.AgentStateStore; import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; @@ -25,14 +26,21 @@ import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.UUID; import java.util.function.Function; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.closed; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.read; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.require; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.resource; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.safeNumber; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.text; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.validAnswers; +import static com.alibaba.qwen.code.managedagent.persistence.ManagedActionJournalValidation.validateQuestionResources; + @Repository @Qualifier("managedAgentTransactionManager") public class ManagedActionStore @@ -192,13 +200,15 @@ public class ManagedActionStore String state = payload.path("state").asText(); require( id.matches("tool_approval_[0-9a-f]{32}") - && "permission".equals(payload.path("kind").asText()) + && List.of("permission", "question").contains(payload.path("kind").asText()) && "tool_call".equals(payload.path("source").asText()) && List.of("requested", "decided", "expired", "cancelled") .contains(state)); JsonNode ref = payload.path("optionsRef"); StoredResource resource = resource(ref, "managed-action-options", resources); JsonNode options = read(new String(resource.bytes(), StandardCharsets.UTF_8)); + boolean question = ManagedUserQuestions.isQuestion(options); + require((question ? "question" : "permission").equals(payload.path("kind").asText())); List optionFields = new ArrayList<>(List.of( "v", "requestId", @@ -209,11 +219,11 @@ public class ManagedActionStore "inputRevision", "createdAt", "expiresAt", - "options")); - if (options.path("v").asLong() == 2 || options.path("v").asLong() == 3 && options.has("inputRef")) { + question ? "questions" : "options")); + if (question || options.path("v").asLong() == 2 || options.path("v").asLong() == 3 && options.has("inputRef")) { optionFields.add("inputRef"); } - if (options.path("v").asLong() == 3) { + if (question || options.path("v").asLong() == 3) { optionFields.add("continuationRef"); } if (options.has("requiresHumanDecision")) { @@ -224,7 +234,7 @@ public class ManagedActionStore require( safeNumber(options.path("v")) && (options.path("v").asLong() == 1 - || options.path("v").asLong() == 2 || options.path("v").asLong() == 3) + || options.path("v").asLong() == 2 || options.path("v").asLong() == 3 || question) && id.equals(options.path("requestId").asText()) && safeNumber(options.path("inputRevision")) && options.path("inputRevision").asLong() @@ -232,21 +242,24 @@ public class ManagedActionStore && options.path("inputRevision").asLong() >= 1 && options.path("policyRevision") .asText() - .equals("hosted-tool-approval/1") + .equals(question ? ManagedUserQuestions.POLICY : "hosted-tool-approval/1") && text(options.path("turnId")) && text(options.path("functionCallId")) && text(options.path("toolName")) && safeNumber(options.path("createdAt")) && safeNumber(options.path("expiresAt")) && options.path("expiresAt").asLong() - > options.path("createdAt").asLong() - && options.path("options").isArray() - && options.path("options").size() == 2 - && "allow".equals(options.path("options").get(0).path("id").asText()) - && "deny".equals(options.path("options").get(1).path("id").asText())); - for (JsonNode option : options.path("options")) { - closed(option, "id", "label"); - require(text(option.path("label"))); + > options.path("createdAt").asLong()); + if (question) { + validateQuestionResources(sessionId, options, resources); + } else { + require(options.path("options").isArray() && options.path("options").size() == 2 + && "allow".equals(options.path("options").get(0).path("id").asText()) + && "deny".equals(options.path("options").get(1).path("id").asText())); + for (JsonNode option : options.path("options")) { + closed(option, "id", "label"); + require(text(option.path("label"))); + } } if (options.has("inputRef")) { String inputKind = options.path("inputRef").path("kind").asText(); @@ -265,7 +278,7 @@ public class ManagedActionStore && options.path("functionCallId").equals(continuation.path("calls").get(ordinal).path("call").path("callId"))); } } - if (options.path("v").asLong() == 3) { + if (question || options.path("v").asLong() == 3) { ManagedHostedRecoveryRecords.reference(options.path("continuationRef"), "hosted-approval-continuation", tenantId, workspaceId, sessionId, resources, hostedReferences); } Action previous = find(tenantId, sessionId, id).orElse(null); @@ -281,11 +294,12 @@ public class ManagedActionStore if ("decided".equals(state)) { StoredResource decided = resource(decision, "managed-action-decision", resources); JsonNode body = read(new String(decided.bytes(), StandardCharsets.UTF_8)); - closed(body, "v", "optionId", "inputRevision", "policyRevision"); + closed(body, "v", question ? "answers" : "optionId", "inputRevision", "policyRevision"); require( safeNumber(body.path("v")) - && body.path("v").asLong() == 1 - && List.of("allow", "deny").contains(body.path("optionId").asText()) + && body.path("v").asLong() == (question ? 2 : 1) + && (question ? validAnswers(options, body.path("answers")) + : List.of("allow", "deny").contains(body.path("optionId").asText())) && body.path("inputRevision").equals(options.path("inputRevision")) && body.path("policyRevision") .equals(options.path("policyRevision")) @@ -676,8 +690,9 @@ public class ManagedActionStore "Only the Session owner with current OPERATOR access may approve a public plan."); } if (body.size() != 3 - || !body.path("optionId").isTextual() - || !List.of("allow", "deny").contains(body.path("optionId").asText()) + || (ManagedUserQuestions.isQuestion(o) ? !validAnswers(o, body.path("answers")) + : !body.path("optionId").isTextual() + || !List.of("allow", "deny").contains(body.path("optionId").asText())) || !body.path("inputRevision").isIntegralNumber() || body.path("inputRevision").asLong() != o.path("inputRevision").asLong() || !body.path("policyRevision").equals(o.path("policyRevision"))) { @@ -812,57 +827,4 @@ public class ManagedActionStore owner, op.claimGeneration()); } - - private static StoredResource resource( - JsonNode ref, String kind, Function resources) { - closed(ref, "resourceId", "kind", "schemaVersion", "byteLength", "digest"); - require( - text(ref.path("resourceId")) - && kind.equals(ref.path("kind").asText()) - && safeNumber(ref.path("schemaVersion")) - && ref.path("schemaVersion").asLong() == 1 - && safeNumber(ref.path("byteLength")) - && ref.path("digest").asText().matches("[0-9a-f]{64}")); - StoredResource resource = resources.apply(ref.path("resourceId").asText()); - require( - resource != null - && kind.equals(resource.kind()) - && resource.schemaVersion() == 1 - && resource.byteLength() == ref.path("byteLength").asLong() - && resource.digest().equals(ref.path("digest").asText())); - return resource; - } - - private static void closed(JsonNode value, String... fields) { - require(value.isObject()); - Set actual = new HashSet<>(); - value.fieldNames().forEachRemaining(actual::add); - require(actual.equals(Set.of(fields))); - } - - private static boolean safeNumber(JsonNode value) { - return value.isIntegralNumber() - && value.canConvertToLong() - && value.asLong() >= 0 - && value.asLong() <= 9007199254740991L; - } - - private static boolean text(JsonNode value) { - return value.isTextual() && !value.asText().isBlank(); - } - - private JsonNode read(String value) { - JsonNode result = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(value); - require(result != null); - return result; - } - - private static void require(boolean valid) { - if (!valid) { - throw new ManagedAgentException( - 400, - "managed_session_action_rejected", - "The Action journal record is invalid."); - } - } } diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedActionController.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedActionController.java index 73b4573afe3..603f1c76314 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedActionController.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedActionController.java @@ -64,7 +64,7 @@ public class ManagedActionController { response.kind(), response.inputRevision(), response.policyRevision(), - response.optionId()); + response.optionId(), response.answers(), true); return ResponseEntity.accepted() .header("X-Qwen-Idempotent-Replay", Boolean.toString(admission.replayed())) .body(actions.publicOperation(admission.operation(), admission.replayed())); @@ -106,7 +106,7 @@ public class ManagedActionController { response.kind(), response.inputRevision(), response.policyRevision(), - response.optionId()); + response.optionId(), response.answers(), false); return ResponseEntity.accepted() .body(actions.webOperation(admission.operation(), admission.replayed())); } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ActionJournal.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ActionJournal.java index b402dec553a..813cc446caf 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ActionJournal.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ActionJournal.java @@ -2,11 +2,13 @@ package com.alibaba.qwen.code.managedagent; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedUserQuestions; import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -17,6 +19,9 @@ final class ActionJournal { final String id = "tool_approval_" + UUID.randomUUID().toString().replace("-", ""); final ObjectNode options; private final ExtensionRecordJournal journal; + private final String tenant; + private final String session; + private final List questionResources = new ArrayList<>(); private byte[] input; private int changes; @@ -25,15 +30,17 @@ final class ActionJournal { this( new ExtensionRecordJournal(store, tenant, "workspace-actions", session).open(), created, - expiry); + expiry, tenant, session); } ActionJournal(ActionJournal previous, long created, long expiry) { - this(previous.journal, created, expiry); + this(previous.journal, created, expiry, previous.tenant, previous.session); } - private ActionJournal(ExtensionRecordJournal journal, long created, long expiry) { + private ActionJournal(ExtensionRecordJournal journal, long created, long expiry, String tenant, String session) { this.journal = journal; + this.tenant = tenant; + this.session = session; options = JSON.createObjectNode() .put("v", 1) @@ -56,12 +63,51 @@ final class ActionJournal { return this; } + ActionJournal withQuestions(JsonNode questions) { + ObjectNode payload = JSON.createObjectNode().put("toolName", "ask_user_question"); + payload.set("input", questions); + withInput(ExtensionRecordJournal.bytes(JSON.createObjectNode().put("harnessSessionId", session) + .put("runtimeSessionId", "turn-actions").put("payloadJson", payload.toString()))); + ObjectNode definition = questionResource("managed-definition", JSON.createObjectNode().put("fixture", "definition")); + ObjectNode continuation = JSON.createObjectNode().put("v", 1).put("promptId", "turn-actions") + .put("batchId", "batch-question").put("model", "model").put("round", 0) + .put("stage", "approval").put("approvalOrdinal", 0).put("actionId", id); + continuation.putObject("sessionKey").put("tenantId", tenant).put("workspaceId", "workspace-actions").put("sessionId", session); + continuation.putObject("sourceActivation").put("activationId", "activation-actions").put("epoch", 1); + continuation.putObject("runtime").put("runtimeSessionId", "turn-actions").put("bindingId", "binding") + .put("generation", "1").put("workspaceGeneration", "1"); + continuation.set("definitionRef", definition); + continuation.set("rootSnapshotRef", questionResource("managed-checkpoint", JSON.createObjectNode().put("fixture", "snapshot"))); + continuation.set("assistantRef", questionResource("managed-message", JSON.createObjectNode().put("fixture", "assistant"))); + ObjectNode call = continuation.putArray("calls").addObject().put("runtimeCallId", "runtime-call") + .put("requestDigest", "digest").put("argsDigest", "digest").put("prepareKey", "prepare") + .put("prepareReference", "reference").put("partIndex", 0).put("actionId", id); + call.putNull("refusal"); + call.putObject("call").put("name", "ask_user_question").put("callId", "call-actions"); + call.set("inputRef", options.get("inputRef")); + call.set("definitionRef", definition); + options.put("v", 4).put("toolName", "ask_user_question").put("policyRevision", ManagedUserQuestions.POLICY) + .put("requiresHumanDecision", true); + options.remove("options"); + options.set("questions", ManagedUserQuestions.questionsFromInput(questions)); + options.set("continuationRef", questionResource("hosted-approval-continuation", continuation)); + return this; + } + + private ObjectNode questionResource(String kind, JsonNode body) { + byte[] bytes = ExtensionRecordJournal.bytes(body); + questionResources.add(resource(bytes, kind)); + return reference(bytes, kind); + } + CommitTransactionRequest request(String state, JsonNode response) throws Exception { byte[] bytes = ExtensionRecordJournal.bytes(options); byte[] decision = response == null ? null - : ExtensionRecordJournal.bytes( + : ManagedUserQuestions.isQuestion(options) + ? ManagedUserQuestions.decisionJson(response).getBytes(StandardCharsets.UTF_8) + : ExtensionRecordJournal.bytes( JSON.createObjectNode() .put("v", 1) .put("optionId", response.path("optionId").asText()) @@ -95,7 +141,7 @@ final class ActionJournal { ObjectNode payload = event.putObject("payload") .put("requestId", id) - .put("kind", "permission") + .put("kind", ManagedUserQuestions.isQuestion(options) ? "question" : "permission") .put("source", "tool_call") .put("inputRevision", 1) .put("state", state); @@ -110,6 +156,7 @@ final class ActionJournal { }, records -> records); List resources = new ArrayList<>(); + resources.addAll(questionResources); resources.add(resource(bytes, "managed-action-options")); if (input != null) { resources.add(resource(input, "managed-tool-input")); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java index 50c11dba52f..2933b073f80 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedPublicWorkspaceIT.java @@ -94,7 +94,7 @@ class HostedPublicWorkspaceIT { private boolean shellLifecycleV2; private boolean publicChildren; private boolean publicChildBackground; - private boolean publicChildRestarted; + private boolean restarted; private boolean publicChildFallback; private Map publicationObjects; private String shellFault; @@ -110,6 +110,67 @@ class HostedPublicWorkspaceIT { private final java.util.Set operatorAnswered = new java.util.HashSet<>(); private final java.util.Set fixedAnswered = new java.util.HashSet<>(); + @Test + @Timeout(180) + void structuredQuestionSurvivesJavaAndHarnessRestart() throws Exception { + assertThat(System.getProperty("mysql.url")).startsWith("jdbc:mysql:"); + shell = true; + approvals = true; + definitionApproval = true; + var roots = boot(); + register("workspace-0", "storage-0"); + String session = request("POST", "/api/agent/web-shell/v1/sessions/create", + Map.of("agentId", "qwen-code", "idempotencyKey", "question-session", "input", List.of(), + "approvalMode", "yolo", "workspace", Map.of("workspaceId", "workspace-0", "cwdRelative", "child")), + "question-session", "actor", 202).path("sessionId").asText(); + String turn = request("POST", "/api/agent/web-shell/v1/turns/submit", + Map.of("sessionId", session, "idempotencyKey", "question-turn", + "input", List.of(Map.of("type", "input_text", "text", "G0_USER_QUESTION"))), + "question-turn", "actor", 202).path("turnId").asText(); + JsonNode waiting = await().atMost(Duration.ofSeconds(45)).until(() -> { + assertThat(modelFailure.get()).isNull(); + return request("GET", "/v1/agents/sessions/" + session + "/actions", null, null, "actor", 200).path("data").path(0); + }, action -> "question".equals(action.path("kind").asText())); + assertThat(waiting.path("tool_name").asText()).isEqualTo("ask_user_question"); + assertThat(waiting.at("/questions/0/options/1/id").asText()).isEqualTo("q1o2"); + assertThat(modelRequests).hasSize(1); + var properties = spring.getBean(com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties.class); + int harnessPort = URI.create(properties.getHarness().getBaseUrl()).getPort(); + int brokerPort = properties.getRuntimeBroker().getPort(); + harness.destroyForcibly(); + assertThat(harness.waitFor(10, TimeUnit.SECONDS)).isTrue(); + spring.close(); + restarted = true; + Path cli = Path.of(System.getProperty("qwen.cli.entry", "../../dist/cli.js")).toAbsolutePath(); + startSpring(cli, roots, harnessPort, brokerPort); + startHarness(cli, harnessPort, brokerPort); + JsonNode refreshed = request("POST", "/api/agent/web-shell/v1/actions/query", Map.of("sessionId", session), null, "actor", 200) + .path("data").get(0); + assertThat(refreshed.path("actionId")).isEqualTo(waiting.path("id")); + assertThat(refreshed.path("inputRevision")).isEqualTo(waiting.path("input_revision")); + assertThat(refreshed.path("questions").get(0).path("prompt")).isEqualTo(waiting.path("questions").get(0).path("prompt")); + Map response = Map.of("kind", "question", "inputRevision", refreshed.path("inputRevision").asLong(), + "policyRevision", refreshed.path("policyRevision").asText(), + "answers", List.of(Map.of("questionId", "q1", "optionIds", List.of("q1o2", "q1o1"), "text", "Custom 中😀"))); + Map body = Map.of("sessionId", session, "actionId", refreshed.path("actionId").asText(), + "idempotencyKey", "question-answer", "response", response); + String operation = request("POST", "/api/agent/web-shell/v1/actions/respond", body, "question-answer", "actor", 202) + .path("operationId").asText(); + assertThat(request("POST", "/api/agent/web-shell/v1/actions/respond", body, "question-answer", "actor", 202) + .path("operationId").asText()).isEqualTo(operation); + await().atMost(Duration.ofSeconds(90)).untilAsserted(() -> { + assertThat(modelFailure.get()).isNull(); + assertThat(request("GET", "/v1/agents/sessions/" + session + "/operations/" + operation, + null, null, "actor", 200).path("status").asText()).isEqualTo("completed"); + assertThat(jdbc.queryForObject("SELECT status FROM ma_turn WHERE tenant_id = ? AND session_id = ? AND turn_id = ?", + String.class, tenant, session, turn)).isEqualTo("COMPLETED"); + }); + assertThat(modelRequests).hasSize(2); + assertThat(request("POST", "/api/agent/web-shell/v1/transcript/query", Map.of("sessionId", session), null, "actor", 200) + .toString()).contains("G0_USER_QUESTION_DONE"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_tool_execution WHERE harness_session_id = ?", Long.class, session)).isZero(); + } + @ParameterizedTest @ValueSource(booleans = {false, true}) @Timeout(240) @@ -233,7 +294,7 @@ class HostedPublicWorkspaceIT { harness.destroyForcibly(); assertThat(harness.waitFor(10, TimeUnit.SECONDS)).isTrue(); spring.close(); - publicChildRestarted = true; + restarted = true; Path cli = Path.of(System.getProperty("qwen.cli.entry", "../../dist/cli.js")).toAbsolutePath(); startSpring(cli, roots, harnessPort, brokerPort); startHarness(cli, harnessPort, brokerPort); @@ -1547,7 +1608,7 @@ class HostedPublicWorkspaceIT { }) .run(arguments.toArray(String[]::new)); jdbc = spring.getBean("managedJdbcTemplate", JdbcTemplate.class); - if (!publicChildRestarted && (definitionApproval || "Linux".equals(System.getProperty("os.name")))) { + if (!restarted && (definitionApproval || "Linux".equals(System.getProperty("os.name")))) { var guard = spring.getBean(com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard.class); for (int index = 0; index < roots.size(); index++) { guard.register(tenant, "storage-" + index, UUID.randomUUID().toString()); @@ -1721,7 +1782,7 @@ class HostedPublicWorkspaceIT { assertThat(tools).contains("read_file", "write_file", "edit", "run_shell_command"); assertThat(tools).doesNotContain("team_create", "workflow", "send_message"); } else if (shell) { - var expectedTools = new ArrayList<>(List.of("read_file", "write_file", "edit", "web_fetch", "run_shell_command", "glob")); + var expectedTools = new ArrayList<>(List.of("read_file", "write_file", "edit", "web_fetch", "run_shell_command", "glob", "ask_user_question")); if (shellLifecycleV2) { expectedTools.add("monitor"); } @@ -1734,7 +1795,7 @@ class HostedPublicWorkspaceIT { } else if (resident) { assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit"); } else { - assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit", "web_fetch"); + assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit", "web_fetch", "ask_user_question"); } // Count only this Turn's tool results, after the latest fixture prompt, so a later // Turn in the same Session runs the same write, edit and read sequence. Other user @@ -1758,6 +1819,11 @@ class HostedPublicWorkspaceIT { return; } int step = results.size(); + boolean questionTurn = prompt.get().contains("G0_USER_QUESTION"); + if (questionTurn && step > 0) { + assertThat(results).hasSize(1); + assertThat(results.getFirst().toString()).contains("q1o1", "q1o2", "Custom 中😀"); + } boolean definitionTurn = prompt.get().contains("G0_DEFINITION_APPROVAL_"); boolean runShell = shell && prompt.get().contains("G0_SHELL"); if (!runShell && step == 3) { @@ -1780,7 +1846,7 @@ class HostedPublicWorkspaceIT { var delta = choice.putObject("delta").put("role", "assistant"); boolean publicParent = publicChildren && tools.contains("agent"); boolean toolCall = publicChildren ? publicParent ? step == 0 && prompt.get().contains("G0_PUBLIC_PARENT") : step < 2 - : step < (residentUnsupported ? 1 : definitionTurn ? 2 : runShell ? 1 : 3); + : step < (questionTurn || residentUnsupported ? 1 : definitionTurn ? 2 : runShell ? 1 : 3); if (toolCall) { String name = runShell ? "run_shell_command" : (residentSecond ? List.of("read_file", "edit", "read_file") @@ -1823,11 +1889,16 @@ class HostedPublicWorkspaceIT { + " process.stdout.write('stdout-tail\\n'); process.stderr.write('stderr-tail\\n'); });"; args = Map.of("command", quote(node) + " -e " + quote(producer), "timeout", 60000); } + if (questionTurn) { + name = "ask_user_question"; + args = Map.of("questions", List.of(Map.of("question", "Which targets?", "header", "Targets", "multiSelect", true, + "options", List.of(Map.of("label", "Same", "description", "First"), Map.of("label", "Same", "description", "Second"))))); + } delta.putArray("tool_calls").addObject().put("index", 0).put("id", "g0-tool-" + step) .put("type", "function").putObject("function").put("name", name) .put("arguments", json.writeValueAsString(args)); } else { - delta.put("content", publicChildren && !publicParent ? "G0_PUBLIC_CHILD_DONE" : "G0_DONE"); + delta.put("content", questionTurn ? "G0_USER_QUESTION_DONE" : publicChildren && !publicParent ? "G0_PUBLIC_CHILD_DONE" : "G0_DONE"); } choice.putNull("finish_reason"); String first = "data: " + json.writeValueAsString(chunk) + "\n\n"; diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActionsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActionsTest.java index a2a8882c027..345550059c0 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActionsTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedActionsTest.java @@ -156,6 +156,89 @@ class ManagedActionsTest { }); } + @Test + void structuredQuestionsSurviveRefreshAndReplayAcrossPublicAndWebShell() throws Exception { + String tenant = tenant(); + String session = session(tenant); + JsonNode input = json.readTree(""" + {"questions":[{"question":"Which targets?","header":"Targets","multiSelect":true, + "options":[{"label":"Same","description":"First"},{"label":"Same","description":"Second"}]}]} + """); + ActionJournal journal = new ActionJournal(journals, tenant, session, System.currentTimeMillis(), 9007199254740991L) + .withQuestions(input); + journal.change("requested", null); + for (JsonNode view : inputViews(tenant, session, journal.id)) { + boolean web = view.has("actionId"); + assertThat(view.path("kind").asText()).isEqualTo("question"); + assertThat(view.path(web ? "toolName" : "tool_name").asText()).isEqualTo("ask_user_question"); + assertThat(view.path(web ? "functionCallId" : "function_call_id").asText()).isEqualTo("call-actions"); + assertThat(view.path("questions").get(0).path(web ? "allowText" : "allow_text").asBoolean()).isTrue(); + assertThat(view.at("/questions/0/options/1/id").asText()).isEqualTo("q1o2"); + } + String body = """ + {"kind":"question","input_revision":1,"policy_revision":"hosted-user-question/1", + "answers":[{"question_id":"q1","option_ids":["q1o2","q1o1"],"text":"Extra 中😀"}]} + """; + mvc.perform(auth(post(path(session, journal.id) + "/responses"), tenant, "other") + .header("Idempotency-Key", "question-answer").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isForbidden()); + mvc.perform(auth(get(path(session, journal.id)), tenant(), "owner")).andExpect(status().isNotFound()); + mvc.perform(auth(post(path(session, journal.id) + "/responses"), tenant, "owner") + .header("Idempotency-Key", "invalid-question").contentType(MediaType.APPLICATION_JSON) + .content(body.replace("q1o2", "unknown"))) + .andExpect(status().isBadRequest()); + assertThat(actions.find(tenant, session, journal.id).orElseThrow().state()).isEqualTo("requested"); + AtomicInteger delivered = new AtomicInteger(); + responses.put(journal.id, call -> { + delivered.incrementAndGet(); + JsonNode response = call.getArgument(3); + assertThat(response.at("/answers/0/optionIds/0").asText()).isEqualTo("q1o1"); + journal.change("decided", response); + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceExecutionStore.unavailable(); + }); + JsonNode admitted = readAccepted(auth(post(path(session, journal.id) + "/responses"), tenant, "owner") + .header("Idempotency-Key", "question-answer").contentType(MediaType.APPLICATION_JSON).content(body)); + String operation = admitted.path("id").asText(); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(sessions.findOperation(tenant, session, operation) + .orElseThrow().state()).isEqualTo("COMPLETED")); + JsonNode replay = readAccepted(auth(post("/api/agent/web-shell/v1/actions/respond"), tenant, "owner") + .contentType(MediaType.APPLICATION_JSON).content(json.writeValueAsString(Map.of( + "sessionId", session, "actionId", journal.id, "idempotencyKey", "question-answer", + "response", Map.of("kind", "question", "inputRevision", 1, "policyRevision", "hosted-user-question/1", + "answers", List.of(Map.of("questionId", "q1", "optionIds", List.of("q1o1", "q1o2"), "text", "Extra 中😀"))))))); + assertThat(replay.path("operationId").asText()).isEqualTo(operation); + assertThat(replay.path("replayed").asBoolean()).isTrue(); + assertThat(delivered.get()).isEqualTo(1); + assertThat(actions.find(tenant, session, journal.id).orElseThrow().state()).isEqualTo("decided"); + mvc.perform(auth(post(path(session, journal.id) + "/responses"), tenant, "owner") + .header("Idempotency-Key", "question-answer").contentType(MediaType.APPLICATION_JSON) + .content(body.replace("Extra 中😀", "Different"))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error.code").value("idempotency_conflict")); + } + + @ParameterizedTest + @ValueSource(strings = {"cancelled", "expired"}) + void terminalQuestionsCannotInventAnAnswer(String state) throws Exception { + String tenant = tenant(); + String session = session(tenant); + JsonNode input = json.readTree(""" + {"questions":[{"question":"Which target?","header":"Target", + "options":[{"label":"First","description":"First"},{"label":"Second","description":"Second"}]}]} + """); + ActionJournal journal = new ActionJournal(journals, tenant, session, System.currentTimeMillis(), 9007199254740991L) + .withQuestions(input); + journal.change("requested", null); + journal.change(state, null); + mvc.perform(auth(post(path(session, journal.id) + "/responses"), tenant, "owner") + .header("Idempotency-Key", "terminal-question").contentType(MediaType.APPLICATION_JSON) + .content(""" + {"kind":"question","input_revision":1,"policy_revision":"hosted-user-question/1", + "answers":[{"question_id":"q1","text":"Custom"}]} + """)) + .andExpect(status().isConflict()); + assertThat(actions.find(tenant, session, journal.id).orElseThrow().state()).isEqualTo(state); + } + @Test void aggregatesDirectChildActionsWithOwnerAndScopeBoundPagination() throws Exception { String tenant = tenant(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java index f6b71687d26..a313ec06ba4 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java @@ -165,8 +165,8 @@ class ManagedAgentApiContractTest { entry(ApiModels.WebShellExportPage.class, List.of("WebShellExportPage")), entry(ApiModels.AgentDefinitionRequest.class, List.of("AgentDefinitionRequest")), entry(ApiModels.AgentDefinition.class, List.of("AgentDefinition")), - entry(ApiModels.PermissionResponse.class, List.of("PermissionResponse")), - entry(ApiModels.WebShellPermissionResponse.class, List.of("WebShellPermissionResponse")), + entry(ApiModels.PermissionResponse.class, List.of("ActionResponseRequest")), + entry(ApiModels.WebShellPermissionResponse.class, List.of("WebShellActionResponseRequest")), entry(ApiModels.WebShellActionQueryRequest.class, List.of("WebShellActionQueryRequest")), entry(ApiModels.WebShellActionGetRequest.class, List.of("WebShellActionGetRequest")), entry(ApiModels.WebShellActionRespondRequest.class, List.of("WebShellActionRespondRequest")), diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecordsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecordsTest.java index 3730d6aba24..055d3b668ba 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecordsTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedHostedRecoveryRecordsTest.java @@ -76,8 +76,9 @@ class ManagedHostedRecoveryRecordsTest { .isInstanceOf(RuntimeException.class); } - @Test - void sharesClosureMemoAcrossRootsAndStillRejectsConflictingMetadata() { + @ParameterizedTest + @ValueSource(ints = {3, 4}) + void sharesClosureMemoAcrossRootsAndStillRejectsConflictingMetadata(int version) { byte[] bytes = "{}".getBytes(StandardCharsets.UTF_8); var nested = new ManagedSessionStoreModels.StoredResource("shared", "managed-definition", 1, bytes.length, "a".repeat(64), bytes); @@ -90,7 +91,7 @@ class ManagedHostedRecoveryRecordsTest { return nested; }; for (int i = 0; i < 12; i++) { - byte[] root = json.createObjectNode().put("v", 3).set("definitionRef", ref).toString().getBytes(StandardCharsets.UTF_8); + byte[] root = json.createObjectNode().put("v", version).set("definitionRef", ref).toString().getBytes(StandardCharsets.UTF_8); ManagedHostedRecoveryRecords.validateResource(new ManagedSessionStoreModels.StoredResource( "root-" + i, "managed-action-options", 1, root.length, "b".repeat(64), root), "tenant", "workspace", "session", reader, seen); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestionsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestionsTest.java new file mode 100644 index 00000000000..2aabced8ccd --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/contracts/persistence/ManagedUserQuestionsTest.java @@ -0,0 +1,130 @@ +package com.alibaba.qwen.code.managedagent.contracts.persistence; + +import com.alibaba.qwen.code.managedagent.model.ApiModels; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ManagedUserQuestionsTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + private ObjectNode input(boolean multiple) { + ObjectNode input = JSON.createObjectNode(); + ObjectNode question = input.putArray("questions").addObject().put("question", "Which target?") + .put("header", "Target").put("multiSelect", multiple); + question.putArray("options").addObject().put("label", "Same label").put("description", "First choice"); + question.withArray("options").addObject().put("label", "Same label").put("description", "Second choice"); + return input; + } + + @Test + void preservesDuplicateLabelsWithStableIdsAndCanonicalizesAnswerOrder() throws Exception { + ObjectNode input = input(true); + input.withArray("questions").add(input(false).withArray("questions").get(0)); + ArrayNode questions = ManagedUserQuestions.questionsFromInput(input); + assertThat(questions.get(0).path("options").get(0).path("id").asText()).isEqualTo("q1o1"); + assertThat(questions.get(1).path("options").get(1).path("id").asText()).isEqualTo("q2o2"); + JsonNode answers = JSON.readTree(""" + [{"questionId":"q2","text":"Custom"}, + {"text":"And this","optionIds":["q1o2","q1o1"],"questionId":"q1"}] + """); + JsonNode canonical = ManagedUserQuestions.canonicalAnswers(questions, answers, false); + assertThat(canonical.toString()).isEqualTo("[{\"questionId\":\"q1\",\"optionIds\":[\"q1o1\",\"q1o2\"],\"text\":\"And this\"},{\"questionId\":\"q2\",\"text\":\"Custom\"}]"); + ObjectNode response = JSON.createObjectNode().put("inputRevision", 1).put("policyRevision", ManagedUserQuestions.POLICY); + response.set("answers", canonical); + String digest = ManagedUserQuestions.decisionDigest(response); + response.set("answers", ManagedUserQuestions.canonicalAnswers(questions, canonical, false)); + assertThat(ManagedUserQuestions.decisionDigest(response)).isEqualTo(digest); + } + + @Test + void publicAndPrivateAnswersProduceIdenticalDecisionBytes() throws Exception { + ArrayNode questions = ManagedUserQuestions.questionsFromInput(input(false)); + JsonNode publicAnswer = JSON.readTree("[{\"question_id\":\"q1\",\"option_ids\":[\"q1o2\"]}]"); + JsonNode privateAnswer = JSON.readTree("[{\"questionId\":\"q1\",\"optionIds\":[\"q1o2\"]}]"); + assertThat(ManagedUserQuestions.canonicalAnswers(questions, publicAnswer, true)) + .isEqualTo(ManagedUserQuestions.canonicalAnswers(questions, privateAnswer, false)); + } + + @ParameterizedTest + @ValueSource(strings = { + "[]", "[{}]", "[{\"questionId\":\"q1\"}]", "[{\"questionId\":\"q1\",\"text\":\"\"}]", + "[{\"questionId\":\"q1\",\"text\":false}]", "[{\"questionId\":\"q1\",\"text\":null}]", + "[{\"questionId\":\"q1\",\"optionIds\":[]}]", "[{\"questionId\":\"q1\",\"optionIds\":null}]", + "[{\"questionId\":\"q1\",\"optionIds\":[\"q1o1\",\"q1o1\"]}]", + "[{\"questionId\":\"q1\",\"optionIds\":[\"q2o1\"]}]", + "[{\"questionId\":\"q1\",\"optionIds\":[\"q1o1\",\"q1o2\"]}]", + "[{\"questionId\":\"q1\",\"optionIds\":[\"q1o1\"],\"text\":\"Mixed\"}]", + "[{\"questionId\":\"other\",\"text\":\"Wrong ID\"}]", + "[{\"questionId\":\"q1\",\"text\":\"Answer\",\"unexpected\":true}]" + }) + void refusesMalformedAndAmbiguousAnswers(String body) throws Exception { + JsonNode answers = JSON.readTree(body); + assertThatThrownBy(() -> ManagedUserQuestions.canonicalAnswers( + ManagedUserQuestions.questionsFromInput(input(false)), answers, false)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refusesDuplicateQuestionsMissingRequiredAnswersAndInvalidUnicode() { + ObjectNode input = input(true); + input.withArray("questions").add(input(false).withArray("questions").get(0)); + JsonNode questions = ManagedUserQuestions.questionsFromInput(input); + ArrayNode duplicate = JSON.createArrayNode(); + duplicate.addObject().put("questionId", "q1").put("text", "First"); + duplicate.addObject().put("questionId", "q1").put("text", "Second"); + assertThatThrownBy(() -> ManagedUserQuestions.canonicalAnswers(questions, duplicate, false)).isInstanceOf(IllegalArgumentException.class); + ObjectNode invalid = input(false); + ((ObjectNode) invalid.withArray("questions").get(0)).put("question", "\ud800"); + assertThatThrownBy(() -> ManagedUserQuestions.questionsFromInput(invalid)).isInstanceOf(IllegalArgumentException.class); + ArrayNode loneSurrogate = JSON.createArrayNode(); + loneSurrogate.addObject().put("questionId", "q1").put("text", "\ud800"); + assertThatThrownBy(() -> ManagedUserQuestions.canonicalAnswers(ManagedUserQuestions.questionsFromInput(input(false)), loneSurrogate, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void boundsUtf8AggregateInputAndAnswersIndependentlyOfCharacterLimits() { + ObjectNode oversized = input(false); + oversized.putObject("metadata").put("source", "中".repeat(6000)); + assertThatThrownBy(() -> ManagedUserQuestions.questionsFromInput(oversized)).isInstanceOf(IllegalArgumentException.class); + ObjectNode input = input(false); + input.withArray("questions").add(input(false).withArray("questions").get(0)); + JsonNode questions = ManagedUserQuestions.questionsFromInput(input); + ArrayNode answers = JSON.createArrayNode(); + answers.addObject().put("questionId", "q1").put("text", "中".repeat(6000)); + answers.addObject().put("questionId", "q2").put("text", "中".repeat(6000)); + assertThatThrownBy(() -> ManagedUserQuestions.canonicalAnswers(questions, answers, false)).isInstanceOf(IllegalArgumentException.class); + ((ObjectNode) answers.get(1)).put("text", "Short answer"); + assertThatCode(() -> ManagedUserQuestions.canonicalAnswers(questions, answers, false)).doesNotThrowAnyException(); + } + + @Test + void matchesJavascriptDecisionPropertyOrderUnicodeAndControlEscaping() { + ObjectNode response = JSON.createObjectNode().put("inputRevision", 1).put("policyRevision", ManagedUserQuestions.POLICY); + response.putArray("answers").addObject().put("questionId", "q1").put("text", "中😀/\u001b\n\\\""); + assertThat(ManagedUserQuestions.decisionJson(response)).isEqualTo( + "{\"v\":2,\"answers\":[{\"questionId\":\"q1\",\"text\":\"中😀/\\u001b\\n\\\\\\\"\"}],\"inputRevision\":1,\"policyRevision\":\"hosted-user-question/1\"}"); + } + + @Test + void actionResponseDtoRejectsCoercionMixedBranchesAndUnknownFields() throws Exception { + String valid = "{\"kind\":\"question\",\"input_revision\":1,\"policy_revision\":\"hosted-user-question/1\",\"answers\":[{\"question_id\":\"q1\",\"text\":\"Answer\"}]}"; + assertThat(JSON.readValue(valid, ApiModels.PermissionResponse.class).answers().size()).isEqualTo(1); + for (String invalid : new String[] { + valid.replace("\"kind\":\"question\"", "\"kind\":\"permission\""), + valid.replace("\"input_revision\":1", "\"input_revision\":1,\"option_id\":null"), + valid.replace("\"policy_revision\":\"hosted-user-question/1\"", "\"policy_revision\":1"), + valid.replace("\"input_revision\":1", "\"input_revision\":1,\"unexpected\":true")}) { + assertThatThrownBy(() -> JSON.readValue(invalid, ApiModels.PermissionResponse.class)).isInstanceOf(Exception.class); + } + assertThat(new ApiModels.PermissionResponse("permission", JSON.getNodeFactory().numberNode(1), "policy", "allow").answers()).isNull(); + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java index a1e20e35c89..80984f5392a 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -2581,6 +2581,44 @@ class QwenHostedHarnessConnectorTest { verify(client, times(2)).loadSession(any(LoadHarnessSession.class)); } + @Test + void resolvesQuestionsThroughTheAuthorizedColdWorkspaceAttachment() throws Exception { + HostedHarnessClient client = mock(HostedHarnessClient.class); + HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class); + HarnessSessionRef attached = mock(HarnessSessionRef.class); + when(client.capabilities()).thenReturn(capabilities); + when(capabilities.getBootId()).thenReturn(BOOT_ID); + when(client.loadSession(any(LoadHarnessSession.class))).thenReturn(attached); + when(attached.getHarnessBootId()).thenReturn(BOOT_ID); + when(attached.getApprovalMode()).thenReturn("yolo"); + SessionRecord session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null, + null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1, + new ContextBinding("tenant-a", "selected-workspace", 1, "storage", "child", + WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "yolo", "hosted-workspace-files/1"); + AgentStateStore sessions = mock(AgentStateStore.class); + when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session); + WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class); + ManagedActionStore actions = mock(ManagedActionStore.class); + when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("yolo"); + ManagedAgentProperties properties = properties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + QwenHostedHarnessConnector connector = new QwenHostedHarnessConnector(properties, sessions, execution, actions); + ReflectionTestUtils.setField(connector, "client", client); + String actionId = "tool_approval_" + "b".repeat(32); + var response = new ObjectMapper().readTree(""" + {"answers":[{"questionId":"q1","optionIds":["q1o2"],"text":"Custom 中😀"}], + "inputRevision":7,"policyRevision":"hosted-user-question/1"} + """); + connector.resolveAction("tenant-a", SESSION_ID, actionId, response); + verify(client).resolveQuestion(attached, actionId, + List.of(Map.of("questionId", "q1", "optionIds", List.of("q1o2"), "text", "Custom 中😀")), + 7L, "hosted-user-question/1"); + verify(client, never()).resolveAction(any(), any(), any(), anyLong(), any()); + verify(client).loadSession(any(LoadHarnessSession.class)); + verify(execution, times(2)).authorizeActionResponse(session); + verify(execution).verifyMountForProbe(session.workspace()); + } + @Test void takeoverSnapshotIsReportedUntilItsContinuationIsAdmitted() { HostedHarnessClient client = mock(HostedHarnessClient.class); diff --git a/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties b/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties index 9bb488cd92e..e0a8657e62d 100644 --- a/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties +++ b/packages/managed-agent-server/verification/src/test/resources/class-size-ratchet.properties @@ -25,7 +25,7 @@ com.alibaba.qwen.code.managedagent.persistence.AgentTurnExecution=819 com.alibaba.qwen.code.managedagent.persistence.AutomationLedgerStore=820 com.alibaba.qwen.code.managedagent.persistence.ChildResultRelayStore=878 com.alibaba.qwen.code.managedagent.persistence.JournalCommit=880 -com.alibaba.qwen.code.managedagent.persistence.ManagedActionStore=868 +com.alibaba.qwen.code.managedagent.persistence.ManagedActionStore=830 com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore=1170 com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore=2170 com.alibaba.qwen.code.managedagent.persistence.ToolPublicationDataStore=2696 diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index c05894a04e6..3f1323a7650 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -397,6 +397,26 @@ public final class HostedHarnessClient implements AutoCloseable { String optionId, long inputRevision, String policyRevision) { + resolveActionResponse(session, actionId, Map.of("optionId", optionId, + "inputRevision", inputRevision, "policyRevision", policyRevision)); + } + + public void resolveQuestion( + HarnessSessionRef session, + String actionId, + List> answers, + long inputRevision, + String policyRevision) { + if (answers == null || answers.isEmpty() || answers.size() > 4 + || inputRevision < 1 || inputRevision > 9007199254740991L + || !"hosted-user-question/1".equals(policyRevision)) { + throw new IllegalArgumentException("Invalid question response"); + } + resolveActionResponse(session, actionId, Map.of("answers", answers, + "inputRevision", inputRevision, "policyRevision", policyRevision)); + } + + private void resolveActionResponse(HarnessSessionRef session, String actionId, Map body) { HarnessSessionRef ref = requireSessionRef(session); if (actionId == null || !actionId.matches("tool_approval_[0-9a-f]{32}")) { throw new IllegalArgumentException("Invalid actionId"); @@ -407,13 +427,7 @@ public final class HostedHarnessClient implements AutoCloseable { + "/actions/" + actionId + "/resolve", - Map.of( - "optionId", - optionId, - "inputRevision", - inputRevision, - "policyRevision", - policyRevision), + body, ref.getHarnessSessionId(), ref.getHarnessClientId(), "POST /session/:id/actions/:id/resolve"); diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index 72c1ce8991e..bd979d64960 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -1936,6 +1936,28 @@ class HostedHarnessClientTest { } } + @Test + void questionResolutionCarriesStructuredAnswersAndClientIdentity() { + String action = "tool_approval_" + "b".repeat(32); + AtomicReference payload = new AtomicReference<>(); + server.createContext("/session", exchange -> sendSessionJson(exchange, 200, sessionJson())); + server.createContext("/session/" + SESSION_ID + "/actions/" + action + "/resolve", exchange -> { + assertEquals(CLIENT_ID, exchange.getRequestHeaders().getFirst(HostedHarnessClient.CLIENT_ID_HEADER)); + assertEquals("Bearer harness-token", exchange.getRequestHeaders().getFirst("Authorization")); + payload.set(readBody(exchange)); + sendSessionJson(exchange, 200, "{\"requestId\":\"" + action + "\",\"state\":\"decided\"}"); + }); + try (HostedHarnessClient client = newClient()) { + HarnessSessionRef session = client.createSession(CreateHarnessSession.builder().harnessSessionId(SESSION_ID).build()); + List> answers = List.of(Map.of("questionId", "q1", "optionIds", List.of("q1o2"), "text", "中😀/\\\"")); + client.resolveQuestion(session, action, answers, 1, "hosted-user-question/1"); + assertEquals(Map.of("answers", answers, "inputRevision", 1, "policyRevision", "hosted-user-question/1"), + JsonSupport.parseObject(payload.get(), "Question response")); + assertThrows(IllegalArgumentException.class, () -> client.resolveQuestion(session, action, answers, 1, "hosted-tool-approval/1")); + assertThrows(IllegalArgumentException.class, () -> client.resolveQuestion(session, action, List.of(), 1, "hosted-user-question/1")); + } + } + @Test void rejectsAKnownOutcomeWithoutStatus() { // F1: a "known" outcome without its status must fail at the diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index e60fab5fedc..c17d2e3abc8 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -19752,6 +19752,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +@noble/hashes@1.8.0 +(git+https://github.com/paulmillr/noble-hashes.git) + +The MIT License (MIT) + +Copyright (c) 2022 Paul Miller (https://paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ============================================================ @tanstack/react-table@8.21.3 (https://github.com/TanStack/table.git) diff --git a/packages/web-shell/client/components/managed/ManagedQuestionForm.test.tsx b/packages/web-shell/client/components/managed/ManagedQuestionForm.test.tsx new file mode 100644 index 00000000000..c2e6c15ef3c --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedQuestionForm.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { I18nProvider } from '../../i18n'; +import { ManagedQuestionForm } from './ManagedQuestionForm'; +import { pendingQuestion } from './managed-question.test-fixtures'; + +let root: Root; +let container: HTMLDivElement; +let onSubmit: ReturnType; +let action: ReturnType; + +beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + onSubmit = vi.fn().mockResolvedValue(undefined); + action = pendingQuestion(); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +async function render(disabled = false) { + await act(async () => + root.render( + + + , + ), + ); +} + +function submitButton() { + return container.querySelector('button[type="submit"]')!; +} + +async function clickOption(role: 'radio' | 'checkbox', index: number) { + await act(async () => + container + .querySelectorAll(`[role="${role}"]`) + [index]!.click(), + ); +} + +async function fill(index: number, value: string) { + const input = container.querySelectorAll('textarea')[index]!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +async function submit() { + await act(async () => { + container + .querySelector('form')! + .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); +} + +it('requires every answer and preserves option identities with duplicate and comma labels', async () => { + await render(); + expect(submitButton().disabled).toBe(true); + await clickOption('radio', 1); + expect(submitButton().disabled).toBe(true); + await clickOption('checkbox', 1); + await clickOption('checkbox', 0); + await fill(1, 'also: review, publish'); + expect(submitButton().disabled).toBe(false); + await submit(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + kind: 'question', + answers: [ + { questionId: 'q1', optionIds: ['q1o2'] }, + { + questionId: 'q2', + optionIds: ['q2o1', 'q2o2'], + text: 'also: review, publish', + }, + ], + }); +}); + +it('uses either a single option or text and preserves drafts through refresh and failed delivery', async () => { + onSubmit.mockRejectedValueOnce(new Error('unconfirmed')); + await render(); + await clickOption('radio', 0); + await fill(0, ' A different library '); + expect( + container.querySelectorAll('[role="radio"][aria-checked="true"]').length, + ).toBe(0); + await fill(1, 'Only a custom feature'); + action = { ...action, questions: structuredClone(action.questions) }; + await render(); + expect(container.querySelector('textarea')?.value).toBe( + ' A different library ', + ); + await submit(); + expect(submitButton().disabled).toBe(false); + await submit(); + expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onSubmit.mock.calls[0]).toEqual(onSubmit.mock.calls[1]); + expect(onSubmit.mock.calls[0][0].answers[0]).toEqual({ + questionId: 'q1', + text: ' A different library ', + }); +}); + +it('clears custom text when a single option is selected and fences duplicate submissions', async () => { + let settle!: () => void; + onSubmit.mockImplementation( + () => new Promise((resolve) => (settle = resolve)), + ); + await render(); + await fill(0, 'custom'); + await clickOption('radio', 0); + expect(container.querySelector('textarea')?.value).toBe(''); + await fill(1, 'custom feature'); + await submit(); + await submit(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(submitButton().disabled).toBe(true); + expect( + [...container.querySelectorAll('textarea')].every( + (input) => input.disabled, + ), + ).toBe(true); + await act(async () => settle()); +}); + +it('blocks forbidden or expired questions and enforces UTF-8 answer bounds without truncation', async () => { + await render(true); + await submit(); + expect(onSubmit).not.toHaveBeenCalled(); + await render(); + await fill(0, '界'.repeat(8192)); + await fill(1, '界'.repeat(8192)); + expect(container.querySelector('textarea')?.maxLength).toBe(8192); + expect(container.querySelector('textarea')?.value.length).toBe(8192); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + '32 KiB', + ); + await submit(); + expect(onSubmit).not.toHaveBeenCalled(); + action = { ...action, expiresAt: Date.now() - 1 }; + await render(); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + 'expired', + ); + expect(submitButton().disabled).toBe(true); + await submit(); + expect(onSubmit).not.toHaveBeenCalled(); +}); diff --git a/packages/web-shell/client/components/managed/ManagedQuestionForm.tsx b/packages/web-shell/client/components/managed/ManagedQuestionForm.tsx new file mode 100644 index 00000000000..8c0fbb814bf --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedQuestionForm.tsx @@ -0,0 +1,208 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { useI18n } from '../../i18n'; +import { Button } from '../ui/button'; +import { Checkbox } from '../ui/checkbox'; +import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; +import { Textarea } from '../ui/textarea'; +import type { + ManagedAgentPendingQuestionAction, + ManagedAgentQuestionAnswer, + ManagedAgentQuestionResponse, +} from './managed-agent-provider'; +import { MAX_MANAGED_QUESTION_ANSWER_BYTES } from './managed-question-response'; + +export function ManagedQuestionForm({ + action, + disabled = false, + onSubmit, +}: { + action: ManagedAgentPendingQuestionAction; + disabled?: boolean; + onSubmit(response: ManagedAgentQuestionResponse): Promise; +}) { + const { t } = useI18n(); + const id = useId(); + const [draft, setDraft] = useState< + Record + >({}); + const [submitting, setSubmitting] = useState(false); + const inFlight = useRef(false); + const [expired, setExpired] = useState(() => Date.now() >= action.expiresAt); + useEffect(() => { + setExpired(Date.now() >= action.expiresAt); + const timer = setTimeout( + () => setExpired(true), + Math.max(0, action.expiresAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [action.expiresAt]); + + const answers: ManagedAgentQuestionAnswer[] = action.questions.flatMap( + (question) => { + const entry = draft[question.id]; + const optionIds = question.options + .filter((option) => entry?.optionIds.includes(option.id)) + .map((option) => option.id); + const text = entry?.text; + if (!optionIds.length && !text?.trim()) return []; + return [ + { + questionId: question.id, + ...(optionIds.length ? { optionIds } : {}), + ...(text?.trim() ? { text } : {}), + }, + ]; + }, + ); + const complete = action.questions.every( + (question) => + !question.required || + answers.some((answer) => answer.questionId === question.id), + ); + const tooLarge = + answers.some((answer) => (answer.text?.length ?? 0) > 8192) || + new TextEncoder().encode(JSON.stringify(answers)).byteLength > + MAX_MANAGED_QUESTION_ANSWER_BYTES; + const blocked = disabled || submitting || expired; + + return ( +
{ + event.preventDefault(); + if ( + blocked || + inFlight.current || + !complete || + tooLarge || + Date.now() >= action.expiresAt + ) + return; + inFlight.current = true; + setSubmitting(true); + try { + await onSubmit({ kind: 'question', answers }); + } catch { + // The Action hook owns the error; keep this exact draft for retry. + inFlight.current = false; + setSubmitting(false); + } + }} + > +

{t('managed.question.title')}

+
+ {action.questions.map((question) => { + const entry = draft[question.id] ?? { optionIds: [], text: '' }; + const legendId = `${id}-${question.id}`; + const options = question.options.map((option) => ( + + )); + return ( +
+ + {question.prompt} + {question.multiple && ` (${t('askUser.multiHint')})`} + + {question.multiple ? ( +
{options}
+ ) : ( + + setDraft((current) => ({ + ...current, + [question.id]: { optionIds: [value], text: '' }, + })) + } + disabled={blocked} + > + {options} + + )} + {question.allowText && ( +