mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(managed-agent): support durable structured user questions (#16433)
Connect Hosted question actions, structured answers, durable continuation, and the WebShell question form. Cover canonical response replay and pending-question recovery across Harness and Java context restarts. Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
1a20792cfa
commit
32d22f950c
53 files changed
+3436
-316
No files matched your search
@@ -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.
|
||||
@@ -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 和混合批次支持属于后续工作,不隐式声明支持。
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, ManagedSessionJsonValue>;
|
||||
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(
|
||||
|
||||
@@ -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<unknown> | 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<void>((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,
|
||||
);
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, Record<string, unknown>>();
|
||||
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<HostedActionOptions, { v: 4 }>,
|
||||
): Promise<HostedQuestionAnswer[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+21
-1
@@ -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<Map<String, Object>> answers = new ArrayList<>();
|
||||
for (JsonNode answer : response.get("answers")) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("questionId", answer.path("questionId").textValue());
|
||||
if (answer.has("optionIds")) {
|
||||
List<String> 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
|
||||
|
||||
+1
-1
@@ -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())) {
|
||||
|
||||
+184
@@ -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<String, JsonNode> 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<String> 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<String> required, Set<String> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-2
@@ -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<PermissionResponse> {
|
||||
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<WebShellPermissionResponse> {
|
||||
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<String> 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) {
|
||||
|
||||
+3
@@ -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<String, Object> decision = new LinkedHashMap<>();
|
||||
decision.put("v", 1);
|
||||
decision.put("optionId", response.path("optionId").asText());
|
||||
|
||||
+80
-36
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+41
-9
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+103
@@ -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<String, StoredResource> 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<String, StoredResource> 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<String> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-75
@@ -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<String> 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<String, StoredResource> 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<String> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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()));
|
||||
}
|
||||
|
||||
+52
-5
@@ -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<CommitResource> 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<CommitResource> resources = new ArrayList<>();
|
||||
resources.addAll(questionResources);
|
||||
resources.add(resource(bytes, "managed-action-options"));
|
||||
if (input != null) {
|
||||
resources.add(resource(input, "managed-tool-input"));
|
||||
|
||||
+78
-7
@@ -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<String, byte[]> publicationObjects;
|
||||
private String shellFault;
|
||||
@@ -110,6 +110,67 @@ class HostedPublicWorkspaceIT {
|
||||
private final java.util.Set<String> operatorAnswered = new java.util.HashSet<>();
|
||||
private final java.util.Set<String> 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<String, Object> 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<String, Object> 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";
|
||||
|
||||
+83
@@ -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();
|
||||
|
||||
+2
-2
@@ -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")),
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
+130
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+21
-7
@@ -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<Map<String, Object>> 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<String, Object> 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");
|
||||
|
||||
+22
@@ -1936,6 +1936,28 @@ class HostedHarnessClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void questionResolutionCarriesStructuredAnswersAndClientIdentity() {
|
||||
String action = "tool_approval_" + "b".repeat(32);
|
||||
AtomicReference<String> 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<Map<String, Object>> 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
|
||||
|
||||
+26
@@ -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/[email protected]
|
||||
(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/[email protected]
|
||||
(https://github.com/TanStack/table.git)
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
let action: ReturnType<typeof pendingQuestion>;
|
||||
|
||||
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(
|
||||
<I18nProvider language="en">
|
||||
<ManagedQuestionForm
|
||||
key={`${action.actionId}:${action.inputRevision}`}
|
||||
action={action}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return container.querySelector<HTMLButtonElement>('button[type="submit"]')!;
|
||||
}
|
||||
|
||||
async function clickOption(role: 'radio' | 'checkbox', index: number) {
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelectorAll<HTMLButtonElement>(`[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<void>((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();
|
||||
});
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const id = useId();
|
||||
const [draft, setDraft] = useState<
|
||||
Record<string, { optionIds: string[]; text: string }>
|
||||
>({});
|
||||
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 (
|
||||
<form
|
||||
aria-label={t('managed.question.title')}
|
||||
className="space-y-3 rounded-lg border border-border bg-background p-4"
|
||||
onSubmit={async (event) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{t('managed.question.title')}</h3>
|
||||
<div className="max-h-96 space-y-4 overflow-y-auto">
|
||||
{action.questions.map((question) => {
|
||||
const entry = draft[question.id] ?? { optionIds: [], text: '' };
|
||||
const legendId = `${id}-${question.id}`;
|
||||
const options = question.options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={`${legendId}-${option.id}`}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
{question.multiple ? (
|
||||
<Checkbox
|
||||
id={`${legendId}-${option.id}`}
|
||||
disabled={blocked}
|
||||
checked={entry.optionIds.includes(option.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[question.id]: {
|
||||
...entry,
|
||||
optionIds:
|
||||
checked === true
|
||||
? [...entry.optionIds, option.id]
|
||||
: entry.optionIds.filter(
|
||||
(value) => value !== option.id,
|
||||
),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RadioGroupItem
|
||||
id={`${legendId}-${option.id}`}
|
||||
value={option.id}
|
||||
disabled={blocked}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="block text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
));
|
||||
return (
|
||||
<fieldset
|
||||
key={question.id}
|
||||
disabled={blocked}
|
||||
className="space-y-2"
|
||||
>
|
||||
<legend id={legendId} className="mb-2 text-sm font-medium">
|
||||
{question.prompt}
|
||||
{question.multiple && ` (${t('askUser.multiHint')})`}
|
||||
</legend>
|
||||
{question.multiple ? (
|
||||
<div className="space-y-2">{options}</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
aria-labelledby={legendId}
|
||||
value={entry.optionIds[0] ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[question.id]: { optionIds: [value], text: '' },
|
||||
}))
|
||||
}
|
||||
disabled={blocked}
|
||||
>
|
||||
{options}
|
||||
</RadioGroup>
|
||||
)}
|
||||
{question.allowText && (
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span>{t('managed.question.other')}</span>
|
||||
<Textarea
|
||||
aria-label={`${question.prompt}: ${t('managed.question.other')}`}
|
||||
value={entry.text}
|
||||
maxLength={8192}
|
||||
disabled={blocked}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value;
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[question.id]: {
|
||||
optionIds: question.multiple ? entry.optionIds : [],
|
||||
text,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{expired && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t('managed.question.expired')}
|
||||
</p>
|
||||
)}
|
||||
{tooLarge && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('managed.question.tooLarge')}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={blocked || !complete || tooLarge}>
|
||||
{t(submitting ? 'askUser.submitting' : 'askUser.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isShellResultDisplay } from '@qwen-code/sdk/daemon';
|
||||
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
||||
import { getManagedClientId } from './managed-session-storage';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { pendingQuestion } from './managed-question.test-fixtures';
|
||||
import * as pendingStorage from './managed-pending-storage';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import type {
|
||||
@@ -288,6 +290,7 @@ describe('ManagedSessionsPage', () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function render(
|
||||
@@ -3275,6 +3278,71 @@ describe('ManagedSessionsPage', () => {
|
||||
).toBe('null');
|
||||
});
|
||||
|
||||
it('shows a question without approval buttons and retains its answers until confirmed', async () => {
|
||||
vi.stubGlobal('crypto', webcrypto);
|
||||
mocks.client.getSession.mockImplementation(async (id: string) =>
|
||||
summary(id, {
|
||||
phase: 'agent_running',
|
||||
capabilities: { canSend: false, canCancel: true, actions: true },
|
||||
}),
|
||||
);
|
||||
const question = { ...pendingQuestion(), sessionId: 's1' };
|
||||
const respond = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('unconfirmed'))
|
||||
.mockResolvedValue(undefined);
|
||||
const listPending = vi.fn().mockResolvedValue([question]);
|
||||
provider = { ...provider, actions: { listPending, respond } };
|
||||
await render('s1');
|
||||
await act(async () => flush());
|
||||
const card = container.querySelector('[data-testid="managed-approval"]')!;
|
||||
expect(card.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
expect(card.textContent).not.toContain('Yes, allow once');
|
||||
expect(card.textContent).not.toContain('Tool arguments are unavailable');
|
||||
await act(async () => {
|
||||
for (const [index, input] of card
|
||||
.querySelectorAll('textarea')
|
||||
.entries()) {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(input, `Custom answer ${index}`);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
const submitQuestion = () =>
|
||||
card
|
||||
.querySelector('form')!
|
||||
.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await act(async () => {
|
||||
submitQuestion();
|
||||
await flush();
|
||||
});
|
||||
await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
|
||||
await act(async () => flush());
|
||||
expect(card.querySelector('textarea')?.value).toBe('Custom answer 0');
|
||||
expect(container.textContent).toContain('Retry the same answers');
|
||||
await act(async () => {
|
||||
submitQuestion();
|
||||
await flush();
|
||||
});
|
||||
await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
|
||||
await act(async () => flush());
|
||||
expect(respond.mock.calls[0]).toEqual(respond.mock.calls[1]);
|
||||
expect(respond.mock.calls[0][1]).toEqual({
|
||||
kind: 'question',
|
||||
answers: [
|
||||
{ questionId: 'q1', text: 'Custom answer 0' },
|
||||
{ questionId: 'q2', text: 'Custom answer 1' },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
container.querySelector('[data-testid="managed-approval"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['write_file', { file_path: 'notes.md', content: 'approval-write-body' }],
|
||||
[
|
||||
@@ -3396,13 +3464,13 @@ describe('ManagedSessionsPage', () => {
|
||||
provider = { ...provider, actions: { listPending, respond } };
|
||||
await render('s1');
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Pending approvals could not be loaded',
|
||||
'Pending requests could not be loaded',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-testid="managed-approval"]'),
|
||||
).toBeNull();
|
||||
const retry = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Retry loading approvals',
|
||||
(button) => button.textContent === 'Retry loading requests',
|
||||
);
|
||||
expect(retry).toBeDefined();
|
||||
await act(async () => {
|
||||
@@ -3466,7 +3534,7 @@ describe('ManagedSessionsPage', () => {
|
||||
container.querySelector('[data-testid="managed-approval"]'),
|
||||
).not.toBeNull();
|
||||
const alert = container.querySelector('[role="alert"]')?.textContent ?? '';
|
||||
expect(alert).toContain('Pending approvals could not be refreshed');
|
||||
expect(alert).toContain('Pending requests could not be refreshed');
|
||||
expect(alert).not.toContain('could not be loaded');
|
||||
});
|
||||
|
||||
@@ -3545,7 +3613,7 @@ describe('ManagedSessionsPage', () => {
|
||||
// The latched refusal got its own re-probe affordance, rendered with no
|
||||
// `loadError` in play, so a grant raised while the page is open can stick.
|
||||
const again = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('Retry loading approvals'),
|
||||
(button) => button.textContent?.includes('Retry loading requests'),
|
||||
) as HTMLButtonElement;
|
||||
expect(again).toBeTruthy();
|
||||
await act(async () => {
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from './managed-recovery-handoff';
|
||||
import { useManagedSessionRename } from './use-managed-session-rename';
|
||||
import { useManagedActions } from './use-managed-actions';
|
||||
import { ManagedQuestionForm } from './ManagedQuestionForm';
|
||||
import { useManagedContextCompression } from './use-managed-context-compression';
|
||||
import { useManagedModelChange } from './use-managed-model-change';
|
||||
import { useManagedApprovalChange } from './use-managed-approval-change';
|
||||
@@ -2124,55 +2125,71 @@ function ManagedSessionsContent({
|
||||
{approvals.action?.sessionId}
|
||||
</p>
|
||||
)}
|
||||
<ToolApproval
|
||||
request={pendingApproval}
|
||||
planExecutionMode={
|
||||
approvals.action?.sessionId === sessionId &&
|
||||
approvals.action?.toolName === 'exit_plan_mode' &&
|
||||
approvals.action.turnId === detail.summary?.activeTurnId
|
||||
? detail.summary?.approvalMode
|
||||
: undefined
|
||||
}
|
||||
variant="floating"
|
||||
keyboardActive={false}
|
||||
// Answering needs the Session's owner or a Workspace
|
||||
// operator; once the service refuses this viewer, that
|
||||
// holds for every approval this Session raises — until an
|
||||
// explicit retry re-probes, so the latch is scoped to the
|
||||
// Session rather than the Action.
|
||||
disabled={approvals.respondForbidden || compression.blocked}
|
||||
extraDescriptionId={
|
||||
[
|
||||
pendingApproval.rawInput === undefined
|
||||
? argumentsCaveatId
|
||||
: null,
|
||||
answerNoticeShown ? answerNoticeId : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
onConfirm={(actionId, optionId) => {
|
||||
if (!compression.isBlocked())
|
||||
return approvals.respond(actionId, optionId);
|
||||
return Promise.resolve();
|
||||
}}
|
||||
/>
|
||||
{pendingApproval.rawInput === undefined && (
|
||||
<p
|
||||
id={argumentsCaveatId}
|
||||
role="status"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{approvals.action?.inputPreview
|
||||
? t(
|
||||
approvals.action.inputPreview.truncated
|
||||
? 'managed.approval.previewTruncated'
|
||||
: 'managed.approval.previewComplete',
|
||||
{ bytes: approvals.action.inputPreview.byteLength },
|
||||
)
|
||||
: t('managed.approval.argumentsUnavailable')}
|
||||
</p>
|
||||
{approvals.action?.kind === 'question' ? (
|
||||
<ManagedQuestionForm
|
||||
key={`${provider.storageKey}:${approvals.action.sessionId}:${approvals.action.actionId}:${approvals.action.inputRevision}:${approvals.action.policyRevision}`}
|
||||
action={approvals.action}
|
||||
disabled={approvals.respondForbidden || compression.blocked}
|
||||
onSubmit={(response) => {
|
||||
if (compression.isBlocked())
|
||||
return Promise.reject(
|
||||
new Error('Context compression is pending'),
|
||||
);
|
||||
return approvals.respond(pendingApproval.id, response);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ToolApproval
|
||||
request={pendingApproval}
|
||||
planExecutionMode={
|
||||
approvals.action?.sessionId === sessionId &&
|
||||
approvals.action?.toolName === 'exit_plan_mode' &&
|
||||
approvals.action.turnId === detail.summary?.activeTurnId
|
||||
? detail.summary?.approvalMode
|
||||
: undefined
|
||||
}
|
||||
variant="floating"
|
||||
keyboardActive={false}
|
||||
// Answering needs the Session's owner or a Workspace
|
||||
// operator; once the service refuses this viewer, that
|
||||
// holds for every approval this Session raises — until an
|
||||
// explicit retry re-probes, so the latch is scoped to the
|
||||
// Session rather than the Action.
|
||||
disabled={approvals.respondForbidden || compression.blocked}
|
||||
extraDescriptionId={
|
||||
[
|
||||
pendingApproval.rawInput === undefined
|
||||
? argumentsCaveatId
|
||||
: null,
|
||||
answerNoticeShown ? answerNoticeId : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
onConfirm={(actionId, optionId) => {
|
||||
if (!compression.isBlocked())
|
||||
return approvals.respond(actionId, optionId);
|
||||
return Promise.resolve();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{approvals.action?.kind !== 'question' &&
|
||||
pendingApproval.rawInput === undefined && (
|
||||
<p
|
||||
id={argumentsCaveatId}
|
||||
role="status"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{approvals.action?.inputPreview
|
||||
? t(
|
||||
approvals.action.inputPreview.truncated
|
||||
? 'managed.approval.previewTruncated'
|
||||
: 'managed.approval.previewComplete',
|
||||
{ bytes: approvals.action.inputPreview.byteLength },
|
||||
)
|
||||
: t('managed.approval.argumentsUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{approvals.loadError !== undefined && (
|
||||
@@ -2207,8 +2224,12 @@ function ManagedSessionsContent({
|
||||
>
|
||||
{t(
|
||||
approvals.respondForbidden
|
||||
? 'managed.approval.forbidden'
|
||||
: 'managed.approval.failed',
|
||||
? approvals.action?.kind === 'question'
|
||||
? 'managed.question.forbidden'
|
||||
: 'managed.approval.forbidden'
|
||||
: approvals.action?.kind === 'question'
|
||||
? 'managed.question.failed'
|
||||
: 'managed.approval.failed',
|
||||
)}
|
||||
</p>
|
||||
{approvals.respondForbidden && (
|
||||
|
||||
@@ -177,7 +177,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
/** @description Serves Hosted permission and structured question Actions. Question answers preserve the original question and option IDs, allow one to four answers, and are bounded to 32 KiB of canonical UTF-8 JSON. vote_recorded remains planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
post: operations["queryWebShellActions"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -194,7 +194,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
/** @description Serves Hosted permission and structured question Actions. Question answers preserve the original question and option IDs, allow one to four answers, and are bounded to 32 KiB of canonical UTF-8 JSON. vote_recorded remains planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
post: operations["getWebShellAction"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -211,7 +211,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
/** @description Serves Hosted permission and structured question Actions. Question answers preserve the original question and option IDs, allow one to four answers, and are bounded to 32 KiB of canonical UTF-8 JSON. vote_recorded remains planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
post: operations["respondWebShellAction"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -1667,7 +1667,7 @@ export interface components {
|
||||
questionId: string;
|
||||
optionIds?: string[];
|
||||
text?: string;
|
||||
} | unknown | unknown;
|
||||
} & (unknown | unknown);
|
||||
WebShellActionQuestion: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
@@ -1714,6 +1714,9 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
expiresAt: number;
|
||||
decisionReceiptId?: string;
|
||||
turnId?: string;
|
||||
functionCallId: string;
|
||||
toolName: string;
|
||||
questions: components["schemas"]["WebShellActionQuestion"][];
|
||||
} & unknown;
|
||||
/** @description At most one requested Hosted approval per Turn. allow and deny are stable option IDs. A requested native-tool Action may carry a bounded input preview when the producer supplies an input reference. Version 1 Actions and unavailable inputs omit it. Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs. */
|
||||
@@ -1781,7 +1784,7 @@ export interface components {
|
||||
/** @default 20 */
|
||||
limit?: number;
|
||||
/**
|
||||
* @description Include pending permission Actions of this public root Session and its direct children; each Action retains its actual owning sessionId.
|
||||
* @description Include pending Actions of this public root Session and its direct children; each Action retains its actual owning sessionId.
|
||||
* @default false
|
||||
*/
|
||||
includeChildren?: boolean;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { artifact } from './managed-tool-result.test-fixtures';
|
||||
import { createJavaManagedAgentProvider } from './java-managed-agent-provider';
|
||||
import { pendingQuestion } from './managed-question.test-fixtures';
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
@@ -1954,6 +1955,7 @@ describe('createJavaManagedAgentProvider', () => {
|
||||
...permission,
|
||||
actionId: 'question_1',
|
||||
kind: 'question',
|
||||
state: 'decided',
|
||||
questions: [],
|
||||
},
|
||||
],
|
||||
@@ -2098,6 +2100,69 @@ describe('createJavaManagedAgentProvider', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('lists a durable question and submits its structured answer through the existing operation', async () => {
|
||||
const question = pendingQuestion();
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
...question,
|
||||
source: { kind: 'tool_call', id: question.functionCallId },
|
||||
state: 'requested',
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
operationId: 'question-response',
|
||||
type: 'action_response',
|
||||
status: 'completed',
|
||||
}),
|
||||
);
|
||||
const provider = createJavaManagedAgentProvider({
|
||||
baseUrl: 'https://product.example',
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const pending = await provider.actions!.listPending(question.sessionId, {
|
||||
clientId: 'client',
|
||||
});
|
||||
expect(pending).toEqual([question]);
|
||||
const response = {
|
||||
kind: 'question' as const,
|
||||
answers: [
|
||||
{ questionId: 'q1', optionIds: ['q1o2'] },
|
||||
{ questionId: 'q2', optionIds: ['q2o1', 'q2o2'], text: 'custom' },
|
||||
],
|
||||
};
|
||||
await provider.actions!.respond(question, response, {
|
||||
clientId: 'client',
|
||||
idempotencyKey: 'question:stable-digest',
|
||||
});
|
||||
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({
|
||||
requestId: expect.any(String),
|
||||
sessionId: question.sessionId,
|
||||
actionId: question.actionId,
|
||||
idempotencyKey: 'question:stable-digest',
|
||||
response: {
|
||||
...response,
|
||||
inputRevision: question.inputRevision,
|
||||
policyRevision: question.policyRevision,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
provider.actions!.respond(question, 'allow', {
|
||||
clientId: 'client',
|
||||
idempotencyKey: 'invalid',
|
||||
}),
|
||||
).rejects.toThrow('kind does not match');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('an accepted approval answer', () => {
|
||||
const action = {
|
||||
actionId: 'tool_approval_1',
|
||||
|
||||
@@ -654,7 +654,10 @@ export function createJavaManagedAgentProvider(
|
||||
).values(),
|
||||
];
|
||||
},
|
||||
async respond(action, optionId, command) {
|
||||
async respond(action, response, command) {
|
||||
if ((action.kind === 'question') !== (typeof response !== 'string')) {
|
||||
throw new Error('Action response kind does not match the request');
|
||||
}
|
||||
const accepted = await client.respondAction(
|
||||
{
|
||||
requestId: managedRequestId(),
|
||||
@@ -662,10 +665,11 @@ export function createJavaManagedAgentProvider(
|
||||
sessionId: action.sessionId,
|
||||
actionId: action.actionId,
|
||||
response: {
|
||||
kind: 'permission',
|
||||
inputRevision: action.inputRevision,
|
||||
policyRevision: action.policyRevision,
|
||||
optionId,
|
||||
...(typeof response === 'string'
|
||||
? { kind: 'permission' as const, optionId: response }
|
||||
: response),
|
||||
},
|
||||
},
|
||||
command.signal,
|
||||
@@ -1178,7 +1182,7 @@ function requireContextCompressionOperation(
|
||||
}
|
||||
|
||||
function toPendingAction(action: JavaAgentAction): ManagedAgentPendingAction[] {
|
||||
if (action.kind !== 'permission' || action.state !== 'requested') return [];
|
||||
if (action.state !== 'requested') return [];
|
||||
return [
|
||||
{
|
||||
actionId: action.actionId,
|
||||
@@ -1189,8 +1193,14 @@ function toPendingAction(action: JavaAgentAction): ManagedAgentPendingAction[] {
|
||||
inputRevision: action.inputRevision,
|
||||
policyRevision: action.policyRevision,
|
||||
expiresAt: action.expiresAt,
|
||||
options: action.options.map(({ id, label }) => ({ id, label })),
|
||||
...(action.inputPreview ? { inputPreview: action.inputPreview } : {}),
|
||||
...(action.kind === 'question'
|
||||
? { kind: 'question' as const, questions: action.questions }
|
||||
: {
|
||||
options: action.options.map(({ id, label }) => ({ id, label })),
|
||||
...(action.inputPreview
|
||||
? { inputPreview: action.inputPreview }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -325,8 +325,7 @@ export interface ManagedWorkspaceRegistration {
|
||||
): Promise<ManagedWorkspaceCreateOperation>;
|
||||
}
|
||||
|
||||
/** A Hosted tool approval waiting for the Session owner's answer. */
|
||||
export interface ManagedAgentPendingAction {
|
||||
interface ManagedAgentActionIdentity {
|
||||
actionId: string;
|
||||
sessionId: string;
|
||||
/** Public Turn ID; absent when the Turn could not be resolved. */
|
||||
@@ -336,10 +335,35 @@ export interface ManagedAgentPendingAction {
|
||||
inputRevision: number;
|
||||
policyRevision: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface ManagedAgentPendingPermissionAction
|
||||
extends ManagedAgentActionIdentity {
|
||||
kind?: 'permission';
|
||||
options: Array<{ id: string; label: string }>;
|
||||
inputPreview?: components['schemas']['WebShellActionInputPreview'];
|
||||
}
|
||||
|
||||
export interface ManagedAgentPendingQuestionAction
|
||||
extends ManagedAgentActionIdentity {
|
||||
kind: 'question';
|
||||
questions: components['schemas']['WebShellActionQuestion'][];
|
||||
}
|
||||
|
||||
export type ManagedAgentPendingAction =
|
||||
| ManagedAgentPendingPermissionAction
|
||||
| ManagedAgentPendingQuestionAction;
|
||||
|
||||
export type ManagedAgentQuestionAnswer =
|
||||
components['schemas']['WebShellQuestionAnswer'];
|
||||
|
||||
export interface ManagedAgentQuestionResponse {
|
||||
kind: 'question';
|
||||
answers: ManagedAgentQuestionAnswer[];
|
||||
}
|
||||
|
||||
export type ManagedAgentActionResponse = string | ManagedAgentQuestionResponse;
|
||||
|
||||
export interface ManagedAgentProvider {
|
||||
readonly userMessages?: {
|
||||
query(
|
||||
@@ -480,7 +504,7 @@ export interface ManagedAgentProvider {
|
||||
options: ManagedAgentRequestOptions,
|
||||
): Promise<ManagedMcpManagementSnapshot>;
|
||||
};
|
||||
/** Present when the provider can serve Hosted permission Actions. */
|
||||
/** Present when the provider can serve Hosted Actions. */
|
||||
readonly actions?: {
|
||||
listPending(
|
||||
sessionId: string,
|
||||
@@ -488,7 +512,7 @@ export interface ManagedAgentProvider {
|
||||
): Promise<ManagedAgentPendingAction[]>;
|
||||
respond(
|
||||
action: ManagedAgentPendingAction,
|
||||
optionId: string,
|
||||
response: ManagedAgentActionResponse,
|
||||
options: ManagedAgentCommandOptions,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -51,15 +51,17 @@ export function toManagedPermissionRequest(
|
||||
messages: readonly Message[],
|
||||
): PermissionRequest {
|
||||
const tool = findManagedApprovalTool(messages, action);
|
||||
const inputPreview =
|
||||
action.kind === 'question' ? undefined : action.inputPreview;
|
||||
let rawInput = tool?.args;
|
||||
if (
|
||||
!rawInput &&
|
||||
action.toolName === 'exit_plan_mode' &&
|
||||
action.inputPreview &&
|
||||
!action.inputPreview.truncated
|
||||
inputPreview &&
|
||||
!inputPreview.truncated
|
||||
) {
|
||||
try {
|
||||
const preview: unknown = JSON.parse(action.inputPreview.text);
|
||||
const preview: unknown = JSON.parse(inputPreview.text);
|
||||
if (preview && typeof preview === 'object' && !Array.isArray(preview)) {
|
||||
const input = (preview as Record<string, unknown>)['input'];
|
||||
if (
|
||||
@@ -98,18 +100,20 @@ export function toManagedPermissionRequest(
|
||||
),
|
||||
},
|
||||
]
|
||||
: action.inputPreview
|
||||
? [{ type: 'text', text: escapePreviewText(action.inputPreview.text) }]
|
||||
: inputPreview
|
||||
? [{ type: 'text', text: escapePreviewText(inputPreview.text) }]
|
||||
: [],
|
||||
...(rawInput
|
||||
? { rawInput, contentIsInput: true }
|
||||
: action.inputPreview
|
||||
: inputPreview
|
||||
? { contentIsInput: true }
|
||||
: {}),
|
||||
options: action.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(OPTION_KINDS[option.id] ? { kind: OPTION_KINDS[option.id] } : {}),
|
||||
})),
|
||||
options: (action.kind === 'question' ? [] : action.options).map(
|
||||
(option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(OPTION_KINDS[option.id] ? { kind: OPTION_KINDS[option.id] } : {}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
canonicalQuestionAnswers,
|
||||
questionResponseKey,
|
||||
} from './managed-question-response';
|
||||
import { pendingQuestion } from './managed-question.test-fixtures';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('keeps the same SHA-256 key on ordinary HTTP without Web Crypto', () => {
|
||||
vi.stubGlobal('crypto', undefined);
|
||||
const action = pendingQuestion();
|
||||
const answers = canonicalQuestionAnswers(action, [
|
||||
{ questionId: 'q1', text: 'Custom 中😀 / "quoted"\n' },
|
||||
{ questionId: 'q2', optionIds: ['q2o2', 'q2o1'] },
|
||||
]);
|
||||
const expected = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
action.sessionId,
|
||||
action.actionId,
|
||||
action.inputRevision,
|
||||
action.policyRevision,
|
||||
answers,
|
||||
]),
|
||||
)
|
||||
.digest('hex');
|
||||
expect(questionResponseKey(action, answers)).toBe(`question:${expected}`);
|
||||
});
|
||||
|
||||
it('canonicalizes by immutable IDs and produces a bounded key independent of selection order', async () => {
|
||||
const action = pendingQuestion();
|
||||
const first = canonicalQuestionAnswers(action, [
|
||||
{ questionId: 'q2', optionIds: ['q2o2', 'q2o1'], text: ' my answer ' },
|
||||
{ questionId: 'q1', optionIds: ['q1o2'] },
|
||||
]);
|
||||
const second = canonicalQuestionAnswers(action, [
|
||||
{ questionId: 'q1', optionIds: ['q1o2'] },
|
||||
{ questionId: 'q2', optionIds: ['q2o1', 'q2o2'], text: ' my answer ' },
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
const key = await questionResponseKey(action, first);
|
||||
expect(key).toMatch(/^question:[0-9a-f]{64}$/);
|
||||
expect(key).not.toContain('my answer');
|
||||
expect(await questionResponseKey(structuredClone(action), second)).toBe(key);
|
||||
expect(
|
||||
await questionResponseKey({ ...action, inputRevision: 2 }, second),
|
||||
).not.toBe(key);
|
||||
expect(
|
||||
await questionResponseKey(
|
||||
{ ...action, sessionId: 'other-session' },
|
||||
second,
|
||||
),
|
||||
).not.toBe(key);
|
||||
expect(
|
||||
await questionResponseKey(action, [{ questionId: 'q1', text: 'changed' }]),
|
||||
).not.toBe(key);
|
||||
});
|
||||
|
||||
it('retains invalid IDs for server rejection instead of silently discarding answers', () => {
|
||||
expect(
|
||||
canonicalQuestionAnswers(pendingQuestion(), [
|
||||
{ questionId: 'q1', optionIds: ['unknown', 'q1o1', 'q1o1'] },
|
||||
{ questionId: 'unknown', text: 'not discarded' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ questionId: 'q1', optionIds: ['q1o1', 'q1o1', 'unknown'] },
|
||||
{ questionId: 'unknown', text: 'not discarded' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds serialized UTF-8 answers without trimming or truncating them', () => {
|
||||
const answers = [{ questionId: 'q1', text: '界'.repeat(12_000) }];
|
||||
expect(() => canonicalQuestionAnswers(pendingQuestion(), answers)).toThrow(
|
||||
'exceed 32 KiB',
|
||||
);
|
||||
expect(answers[0].text.length).toBe(12_000);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import type {
|
||||
ManagedAgentPendingQuestionAction,
|
||||
ManagedAgentQuestionAnswer,
|
||||
} from './managed-agent-provider';
|
||||
|
||||
export const MAX_MANAGED_QUESTION_ANSWER_BYTES = 32 * 1024;
|
||||
|
||||
export function canonicalQuestionAnswers(
|
||||
action: ManagedAgentPendingQuestionAction,
|
||||
answers: readonly ManagedAgentQuestionAnswer[],
|
||||
): ManagedAgentQuestionAnswer[] {
|
||||
const questionOrder = new Map(
|
||||
action.questions.map((question, index) => [question.id, index]),
|
||||
);
|
||||
const ordered = answers.map((answer) => {
|
||||
const question = action.questions.find(
|
||||
(candidate) => candidate.id === answer.questionId,
|
||||
);
|
||||
const optionOrder = new Map(
|
||||
question?.options.map((option, index) => [option.id, index]),
|
||||
);
|
||||
return {
|
||||
questionId: answer.questionId,
|
||||
...(answer.optionIds === undefined
|
||||
? {}
|
||||
: {
|
||||
optionIds: [...answer.optionIds].sort(
|
||||
(a, b) =>
|
||||
(optionOrder.get(a) ?? Infinity) -
|
||||
(optionOrder.get(b) ?? Infinity),
|
||||
),
|
||||
}),
|
||||
...(answer.text === undefined ? {} : { text: answer.text }),
|
||||
};
|
||||
});
|
||||
ordered.sort(
|
||||
(a, b) =>
|
||||
(questionOrder.get(a.questionId) ?? Infinity) -
|
||||
(questionOrder.get(b.questionId) ?? Infinity),
|
||||
);
|
||||
if (
|
||||
new TextEncoder().encode(JSON.stringify(ordered)).byteLength >
|
||||
MAX_MANAGED_QUESTION_ANSWER_BYTES
|
||||
) {
|
||||
throw Object.assign(new Error('Question answers exceed 32 KiB'), {
|
||||
code: 'question_answer_too_large',
|
||||
});
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function questionResponseKey(
|
||||
action: ManagedAgentPendingQuestionAction,
|
||||
answers: readonly ManagedAgentQuestionAnswer[],
|
||||
): string {
|
||||
const digest = sha256(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify([
|
||||
action.sessionId,
|
||||
action.actionId,
|
||||
action.inputRevision,
|
||||
action.policyRevision,
|
||||
answers,
|
||||
]),
|
||||
),
|
||||
);
|
||||
return `question:${Array.from(digest, (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('')}`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ManagedAgentPendingQuestionAction } from './managed-agent-provider';
|
||||
|
||||
export function pendingQuestion(): ManagedAgentPendingQuestionAction {
|
||||
return {
|
||||
kind: 'question',
|
||||
actionId: 'tool_approval_question',
|
||||
sessionId: 'session-1',
|
||||
turnId: 'turn-1',
|
||||
functionCallId: 'call-1',
|
||||
toolName: 'ask_user_question',
|
||||
inputRevision: 1,
|
||||
policyRevision: 'hosted-user-question/1',
|
||||
expiresAt: Date.now() + 600_000,
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
prompt: 'Choose the library',
|
||||
required: true,
|
||||
multiple: false,
|
||||
allowText: true,
|
||||
options: [
|
||||
{ id: 'q1o1', label: 'Same, label', description: 'First library' },
|
||||
{ id: 'q1o2', label: 'Same, label', description: 'Second library' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
prompt: 'Choose the features',
|
||||
required: true,
|
||||
multiple: true,
|
||||
allowText: true,
|
||||
options: [
|
||||
{ id: 'q2o1', label: 'Read, write', description: 'File access' },
|
||||
{ id: 'q2o2', label: 'Search', description: 'Code lookup' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from './managed-agent-provider';
|
||||
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
||||
import { useManagedActions } from './use-managed-actions';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { pendingQuestion } from './managed-question.test-fixtures';
|
||||
|
||||
const pending: ManagedAgentPendingAction = {
|
||||
actionId: 'tool_approval_1',
|
||||
@@ -48,6 +50,7 @@ describe('useManagedActions', () => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mount(
|
||||
@@ -122,6 +125,102 @@ describe('useManagedActions', () => {
|
||||
expect(hook.latest?.answerError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a question visible while answering and reuses the canonical response key on retry and reload', async () => {
|
||||
vi.stubGlobal('crypto', webcrypto);
|
||||
const question = pendingQuestion();
|
||||
const listPending = vi.fn().mockResolvedValue([question]);
|
||||
let reject!: (error: Error) => void;
|
||||
const respond = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<void>((_resolve, fail) => (reject = fail)),
|
||||
)
|
||||
.mockRejectedValue(new Error('unconfirmed'));
|
||||
const provider = {
|
||||
actions: { listPending, respond },
|
||||
} as unknown as ManagedAgentProvider;
|
||||
const hook = mount(provider, { enabled: true, events: [] });
|
||||
await vi.waitFor(() => expect(hook.latest?.action).toEqual(question));
|
||||
const response = {
|
||||
kind: 'question' as const,
|
||||
answers: [
|
||||
{
|
||||
questionId: 'q2',
|
||||
optionIds: ['q2o2', 'q2o1'],
|
||||
text: 'custom, value',
|
||||
},
|
||||
{ questionId: 'q1', optionIds: ['q1o2'] },
|
||||
],
|
||||
};
|
||||
let first!: Promise<void>;
|
||||
await act(async () => {
|
||||
first = hook.latest!.respond(question.actionId, response);
|
||||
await hook.latest!.respond(question.actionId, response);
|
||||
});
|
||||
await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
|
||||
expect(hook.latest?.action).toEqual(question);
|
||||
const key = respond.mock.calls[0][2].idempotencyKey;
|
||||
expect(key).toMatch(/^question:[0-9a-f]{64}$/);
|
||||
await act(async () => {
|
||||
reject(new Error('unconfirmed'));
|
||||
await expect(first).rejects.toThrow('unconfirmed');
|
||||
});
|
||||
await act(async () => {
|
||||
await expect(
|
||||
hook.latest!.respond(question.actionId, {
|
||||
...response,
|
||||
answers: [...response.answers].reverse().map((answer) => ({
|
||||
...answer,
|
||||
optionIds: [...answer.optionIds].reverse(),
|
||||
})),
|
||||
}),
|
||||
).rejects.toThrow('unconfirmed');
|
||||
});
|
||||
expect(respond.mock.calls[1][2].idempotencyKey).toBe(key);
|
||||
act(() => root!.unmount());
|
||||
root = undefined;
|
||||
const reloaded = mount(provider, { enabled: true, events: [] });
|
||||
await vi.waitFor(() => expect(reloaded.latest?.action).toEqual(question));
|
||||
respond.mockResolvedValueOnce(undefined);
|
||||
await act(async () =>
|
||||
reloaded.latest!.respond(question.actionId, response),
|
||||
);
|
||||
expect(respond.mock.calls[2][2].idempotencyKey).toBe(key);
|
||||
expect(reloaded.latest?.action).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reconciles a question answered by another client and refreshes after a stream gap', async () => {
|
||||
vi.stubGlobal('crypto', webcrypto);
|
||||
const question = pendingQuestion();
|
||||
const listPending = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([question])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValue([question]);
|
||||
const respond = vi
|
||||
.fn()
|
||||
.mockRejectedValue({ code: 'action_already_resolved' });
|
||||
const hook = mount(
|
||||
{ actions: { listPending, respond } } as unknown as ManagedAgentProvider,
|
||||
{ enabled: true, events: [] },
|
||||
);
|
||||
await vi.waitFor(() => expect(hook.latest?.action).toEqual(question));
|
||||
await act(async () =>
|
||||
hook.latest!.respond(question.actionId, {
|
||||
kind: 'question',
|
||||
answers: [
|
||||
{ questionId: 'q1', text: 'custom' },
|
||||
{ questionId: 'q2', optionIds: ['q2o1'] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
||||
expect(hook.latest?.action).toBeUndefined();
|
||||
hook.rerender({ events: [gap(9)] });
|
||||
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(3));
|
||||
expect(hook.latest?.answerError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops an aggregate response after changing credentials even if the provider ignores abort', async () => {
|
||||
let resolve!: (actions: ManagedAgentPendingAction[]) => void;
|
||||
const listPending = vi
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
ManagedAgentPendingAction,
|
||||
ManagedAgentActionResponse,
|
||||
ManagedAgentProvider,
|
||||
ManagedAgentSessionEvent,
|
||||
} from './managed-agent-provider';
|
||||
import { isNonRetryableClientError } from './managed-request-error';
|
||||
import {
|
||||
canonicalQuestionAnswers,
|
||||
questionResponseKey,
|
||||
} from './managed-question-response';
|
||||
|
||||
// Re-reads shortly after the earliest expiry so an unanswered approval leaves
|
||||
// the page once the Harness has ended it.
|
||||
@@ -63,7 +68,10 @@ export interface ManagedActionsState {
|
||||
* re-probes instead of treating one 403 as permanent for the mount.
|
||||
*/
|
||||
respondForbidden: boolean;
|
||||
respond(actionId: string, optionId: string): Promise<void>;
|
||||
respond(
|
||||
actionId: string,
|
||||
response: ManagedAgentActionResponse,
|
||||
): Promise<void>;
|
||||
/** Reads the pending approvals again now. */
|
||||
retry(): void;
|
||||
}
|
||||
@@ -122,6 +130,7 @@ export function useManagedActions(
|
||||
// read that still lists one shows it again, so a wrong report cannot hide
|
||||
// an approvable Action.
|
||||
const endedAnswers = useRef(new Set<string>());
|
||||
const inFlight = useRef(new Map<string, typeof owner>());
|
||||
const trigger = useMemo(() => {
|
||||
let last = 0;
|
||||
for (const event of events) {
|
||||
@@ -259,25 +268,42 @@ export function useManagedActions(
|
||||
current.current = { owner, actions };
|
||||
|
||||
const respond = useCallback(
|
||||
async (actionId: string, optionId: string) => {
|
||||
async (actionId: string, response: ManagedAgentActionResponse) => {
|
||||
const target = actions.find(
|
||||
(entry) =>
|
||||
entry.actionId === actionId && !answered.has(actionKey(entry)),
|
||||
);
|
||||
if (!target || !reader) return;
|
||||
const key = actionKey(target);
|
||||
if (inFlight.current.get(key) === owner) return;
|
||||
inFlight.current.set(key, owner);
|
||||
const isPending = () =>
|
||||
current.current.owner === owner &&
|
||||
current.current.actions.some((entry) => actionKey(entry) === key);
|
||||
setAnswered((current) => new Set(current).add(key));
|
||||
if (target.kind !== 'question')
|
||||
setAnswered((current) => new Set(current).add(key));
|
||||
try {
|
||||
// One key per Action and option: a retried click replays the same
|
||||
// durable operation instead of answering twice.
|
||||
await reader.respond(target, optionId, {
|
||||
if ((target.kind === 'question') !== (typeof response !== 'string')) {
|
||||
throw new Error('Action response kind does not match the request');
|
||||
}
|
||||
const submitted =
|
||||
target.kind === 'question' && typeof response !== 'string'
|
||||
? {
|
||||
kind: 'question' as const,
|
||||
answers: canonicalQuestionAnswers(target, response.answers),
|
||||
}
|
||||
: response;
|
||||
const idempotencyKey =
|
||||
target.kind === 'question' && typeof submitted !== 'string'
|
||||
? await questionResponseKey(target, submitted.answers)
|
||||
: `${target.actionId}:${submitted}`;
|
||||
if (!isPending()) return;
|
||||
await reader.respond(target, submitted, {
|
||||
clientId,
|
||||
idempotencyKey: `${target.actionId}:${optionId}`,
|
||||
idempotencyKey,
|
||||
});
|
||||
if (!isPending()) return;
|
||||
setAnswered((current) => new Set(current).add(key));
|
||||
if (answerFailure.current === key) {
|
||||
setAnswerError(undefined);
|
||||
answerFailure.current = undefined;
|
||||
@@ -307,6 +333,7 @@ export function useManagedActions(
|
||||
// No retry can succeed, so keep the card hidden and read the list
|
||||
// again instead of offering an answer the service will refuse.
|
||||
endedAnswers.current.add(key);
|
||||
setAnswered((current) => new Set(current).add(key));
|
||||
if (answerFailure.current === key) {
|
||||
answerFailure.current = undefined;
|
||||
setAnswerError(undefined);
|
||||
@@ -322,6 +349,8 @@ export function useManagedActions(
|
||||
setAnswerError(failure);
|
||||
answerFailure.current = key;
|
||||
throw failure;
|
||||
} finally {
|
||||
if (inFlight.current.get(key) === owner) inFlight.current.delete(key);
|
||||
}
|
||||
},
|
||||
[actions, reader, clientId, owner, credentials, answered],
|
||||
|
||||
@@ -384,11 +384,21 @@ const EN: Messages = {
|
||||
'managed.discarded': 'Request discarded. The draft is back in the composer.',
|
||||
'managed.newRequired': 'Start a new task to send another message.',
|
||||
'managed.truncated': '[Details truncated]',
|
||||
'managed.question.title': 'Answer the questions',
|
||||
'managed.question.other': 'Other answer',
|
||||
'managed.question.expired':
|
||||
'This question has expired. Refresh to check the session.',
|
||||
'managed.question.tooLarge':
|
||||
'Keep each text answer within 8192 characters and all answers within 32 KiB.',
|
||||
'managed.question.failed':
|
||||
'The answer could not be confirmed. Retry the same answers or refresh to check their status.',
|
||||
'managed.question.forbidden':
|
||||
"Only the Session's owner or a Workspace operator can answer these questions.",
|
||||
'managed.approval.failed':
|
||||
'The approval answer could not be confirmed. Retry the same option or refresh to check its status.',
|
||||
'managed.approval.loadFailed': 'Pending approvals could not be loaded.',
|
||||
'managed.approval.refreshFailed': 'Pending approvals could not be refreshed.',
|
||||
'managed.approval.retry': 'Retry loading approvals',
|
||||
'managed.approval.loadFailed': 'Pending requests could not be loaded.',
|
||||
'managed.approval.refreshFailed': 'Pending requests could not be refreshed.',
|
||||
'managed.approval.retry': 'Retry loading requests',
|
||||
'managed.approval.forbidden':
|
||||
"Only the Session's owner or a Workspace operator can answer this approval.",
|
||||
'managed.approval.argumentsUnavailable':
|
||||
@@ -5300,11 +5310,20 @@ const ZH: Messages = {
|
||||
'managed.discarded': '已丢弃本次请求。草稿已放回输入框。',
|
||||
'managed.newRequired': '请新建任务后发送消息。',
|
||||
'managed.truncated': '[详情已截断]',
|
||||
'managed.question.title': '请回答以下问题',
|
||||
'managed.question.other': '其他答案',
|
||||
'managed.question.expired': '此问题已过期,请刷新查看会话状态。',
|
||||
'managed.question.tooLarge':
|
||||
'每个文本答案最多 8192 个字符,全部答案最多 32 KiB。',
|
||||
'managed.question.failed':
|
||||
'尚未确认回答结果,请重试相同的答案或刷新查看状态。',
|
||||
'managed.question.forbidden':
|
||||
'只有会话所有者或工作区操作员可以回答这些问题。',
|
||||
'managed.approval.failed':
|
||||
'无法确认审批回答的结果。请重试同一选项,或刷新以查看状态。',
|
||||
'managed.approval.loadFailed': '待审批请求加载失败。',
|
||||
'managed.approval.refreshFailed': '待审批请求刷新失败。',
|
||||
'managed.approval.retry': '重新读取审批',
|
||||
'managed.approval.loadFailed': '待处理请求加载失败。',
|
||||
'managed.approval.refreshFailed': '待处理请求刷新失败。',
|
||||
'managed.approval.retry': '重新读取请求',
|
||||
'managed.approval.forbidden':
|
||||
'只有此会话的拥有者或所属 Workspace 的操作者可以回答这项审批。',
|
||||
'managed.approval.argumentsUnavailable': '此项审批的工具参数暂不可见。',
|
||||
|
||||
@@ -51,6 +51,11 @@ export type {
|
||||
export {
|
||||
type ManagedAgentCommandOptions,
|
||||
type ManagedAgentPendingAction,
|
||||
type ManagedAgentPendingPermissionAction,
|
||||
type ManagedAgentPendingQuestionAction,
|
||||
type ManagedAgentQuestionAnswer,
|
||||
type ManagedAgentQuestionResponse,
|
||||
type ManagedAgentActionResponse,
|
||||
type ManagedAgentProvider,
|
||||
type ManagedAgentRequestOptions,
|
||||
type ManagedAgentRuntimeState,
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@datafe-open/markdown-chart-echarts": "^0.1.12",
|
||||
"@datafe-open/markdown-chart-react": "^0.1.12",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@types/hast": "^3.0.5",
|
||||
|
||||
Generated
+3
@@ -1621,6 +1621,9 @@ importers:
|
||||
'@modelcontextprotocol/ext-apps':
|
||||
specifier: ^1.7.5
|
||||
version: 1.7.5(@modelcontextprotocol/[email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
'@noble/hashes':
|
||||
specifier: 1.8.0
|
||||
version: 1.8.0
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3([email protected]([email protected]))([email protected])
|
||||
|
||||
Reference in new issue
Block a user