feat(managed-agent): support Plan mode and inline attachments (#14531)

This commit is contained in:
jinye authored and GitHub committed 2026-10-18 16:58:07 +08:00
1 parent d1c6f02ff6
commit 97ebffaa70
60 files changed
+2785 -231

No files matched your search

@@ -0,0 +1,79 @@
# Managed Session Plan mode and input attachments
[English](2026-10-18-managed-plan-attachments.md) | [简体中文](2026-10-18-managed-plan-attachments.zh-CN.md)
## Problem and scope
Managed Web Shell currently disables the composer attachment and Plan controls. Its Java API and private Hosted prompt route accept text only, and enabling the controls alone would lose attachment contents or fail admission. Hosted team-member planning is a separate protocol that submits plans to a team leader; it does not implement user approval of a public Session plan.
Support Plan and bounded inline attachments in new and existing Managed Sessions. Reuse the shared composer, existing owner and Workspace authorization, immutable Turn inputs, Action decisions, and recovery machinery. Keep the selected execution permission independent from Plan. Do not change ordinary DAC/CLI planning, team-member planning, AgentDefinition identity, or the Workspace filesystem merely by attaching a file.
## Current state
Java stores normalized inputs in `ma_turn.input_json`, digests the entire input, and materializes user Items for transcript replay. Hosted stores the accepted prompt and admission as immutable resources. The model consumes durable message parts during history replay, but the current initial prompt is flattened to text. Resources and prepared model snapshots have a 64 KiB inline limit.
Session approval selection already has owner authorization, Workspace OPERATOR access, idle and recovery barriers, an idempotent receipt, CAS revision, and a frozen per-Turn execution policy. Native file tool approvals have durable v3 continuations; ordinary Shell, MCP and Hook batches are not all recoverable through that continuation format.
## Plan state and public API
Add migration `V10__session_plan_mode.sql` without rewriting published migrations. Add `ma_session.plan_enabled` with default false and nullable `ma_turn.plan_enabled` for the immutable snapshot. Reuse `approval_revision` for the complete selected execution policy and Plan state. A Plan toggle increments the revision and records the current execution mode in `selected_approval_mode`, preserving the existing selection constraint. Turn admission copies mode, revision and Plan under the existing Session row lock.
Extend the existing Web Shell approval query/change, summary and creation contracts with `planMode`; expose `planModeSupported` separately. An omitted change field preserves the existing Plan value. The original definition approval mode remains unchanged. Use the current owner plus OPERATOR requirement and idle barriers for toggling; an active Turn or pending approval cannot be manually changed. A manual idle exit does not approve any plan. Permission changes preserve Plan and never answer an Action.
Plan is available only to the public Workspace profiles already supporting approval selection: `hosted-workspace-files/1`, `hosted-workspace-shell/1` and `/2`, for built-in and stored definitions. Child, team, workflow, unbound, MCP and Hook profiles are not extended by this feature. Preserve their existing behavior.
## Hosted Plan execution and approval
Add capability `plan-mode/1` and an optional `planMode: true` in the immutable private approval selection. Omit the field for false and legacy Turns, preserving old wire identities. Java must require the capability before dispatch or active-Turn progression that carries Plan. A missing capability fails closed; it must never downgrade a Plan Turn to execution mode.
The tool turn derives its public Plan overlay from the admission and this Turn's durable allowed public `exit_plan_mode` Action. It admits only `read_file`, `glob` when present in the profile, and a dedicated public `exit_plan_mode` control call. All writes, edits, Shell commands, background work, child/workflow/team/message/MCP/skill effects are refused before execution, regardless of `yolo` or Hook permission. Known profile tools blocked by Plan return a read-only refusal. A call outside the underlying profile retains the existing guard and fails the Turn; neither path produces effects or releases Plan. Model instructions explain these restrictions. This first version deliberately does not classify remote Shell commands as read-only.
The public exit tool takes a nonempty Markdown plan and must be the only call in its batch. It always asks the human, even with `yolo`. Keep its declaration stable for a Turn that started in Plan. An allowed Action removes only this Turn's overlay; subsequent calls still use its frozen execution mode. Thus `default` may ask again before a write, whereas `yolo` does not. Denial, expiry and cancellation do not grant execution. A repeated exit after approval reports that planning already ended.
Extend v3 native continuation capture and validation only for the exact single public Plan exit control call. Preserve the existing restrictions on ordinary Shell/MCP/Hook batches. Save its exact input, tool declaration, assistant batch, model round, Action and Runtime generation. The control call must bypass Runtime reserve, prepare, intent and execute. Recovery resumes the original Action and journals its result; it does not ask again or infer authorization from an in-memory flag. Keep existing Runtime acquisition, cleanup and fencing requirements.
When Java projects a valid allowed public Plan Action, verify its input/Turn association, `plan_enabled` snapshot and matching approval revision. In the same SQL transaction, clear Session Plan and increment the Session revision, leaving the Turn snapshot untouched. Public Plan exit requires the recorded Session owner with current OPERATOR access on both file and Shell profiles. Late or unrelated Actions cannot clear a newer selection. This makes refresh and process restart observe the completed transition.
## Attachment contract and model input
Extend the closed public input union with `input_file` carrying `name`, `mime_type`, UTF-8 `text`, and `input_image` carrying `mime_type`, base64 `data`. Existing `input_text` and legacy `text` retain their behavior. Reject mixed or unknown fields, empty attachments, arbitrary URLs, host paths, binary files, malformed base64 and mismatched image signatures. Text attachments use a conservative text MIME/extension allowlist and reject NUL. Images support PNG, JPEG and WebP. PDF, Office documents, audio and video are outside this change.
Normalize files into inline ACP text resources with a generated non-host URI and images into ACP image blocks. Include every attachment byte and its name/MIME in semantic and payload digests. The canonical JSON of a request containing attachments is limited to 48 KiB, including text; Java and Hosted enforce the same limit. Hosted also checks the exact user message against the existing 64 KiB resource limit. Do not increase global storage limits or introduce an upload service, new storage table, automatic truncation or omission.
Advertise `input-attachments/1`. Non-text dispatch requires it; text-only requests remain compatible with older Harnesses. Convert the accepted immutable input to the same model parts for user journaling, initial generation, hooks and restoration. Text resources are presented with an explicit filename boundary; images become `inlineData`. Hook updates to the user's text retain attachment parts. Validate image support after applying the final pinned/selected model configuration, including retained image history; a text-only model must fail explicitly instead of receiving an unsupported-image placeholder.
User Items retain canonical attachment metadata/content through the existing JSON attributes and resource references. Live events, transcript pages, snapshot/resync and refresh reconstruct the same file/image presentation. Attachment selection does not write files into the Workspace; a later model tool call remains subject to Plan and execution permission.
## Composer and retry behavior
Opt Managed composers into the shared add menu, Plan control and attachment ingestion. New Session creation captures its Plan selection before the first Turn. Existing Session state comes from the backend, and toggles use the existing persisted approval-change state machine and readback. Hide the composer under the existing approval overlay, display the submitted plan, and label approval with the frozen execution policy.
Allow attachment-only submission. Encode Blob inputs and validate size, MIME and UTF-8 before constructing an idempotent pending request. Persist the complete canonical input with its request key and Session identity. Refresh and unknown-delivery retries reuse the same snapshot rather than rereading a Blob. Clear the composer only after accepted admission; errors retain the draft and never silently discard an attachment. Scope pending data by provider, tenant, actor and Session, retaining existing uncertainty handling.
## Affected areas
- Web Shell Managed provider types, generated Java API types, composer integration, creation/pending-request state, Action presentation and transcript projection.
- Java API/OpenAPI, input validation, approval selection, Turn admission, Action projection/authorization, read models and capability gating.
- Shared schema V10 and Server/Broker initialization/validation.
- Java SDK private approval selection and Hosted client capability checks.
- Hosted prompt parsing, model parts/capability validation, public Plan tool enforcement and exact-single-control continuation recovery.
## Rollout and limits
Capabilities are pinned when the Java Harness client is constructed. If an unsupported generation has already been negotiated, restoring support requires restarting the control plane to construct a new client; a blocked Turn does not automatically probe or bypass its missing capability. Stop or isolate old Server/Broker binaries sharing the catalog before enabling Plan. Apply the additive V9-to-V10 migration only after a backup and isolated migration test. Deploy compatible Java, SDK, Harness and UI together. Old binaries cannot safely operate a catalog containing enabled Plan selections. Keep existing data, Workspace bindings, credentials and immutable definition identity.
48 KiB is a single-input budget, not an unlimited conversation guarantee. A prepared model request includes history, tools and system instructions and can still exceed its existing 64 KiB snapshot budget. Preserve existing explicit recovery limitations in that case; do not claim full recovery or silently drop history. Large-file/object storage, remote Shell classification, extra composer actions, cross-profile inheritance, Linux Hook/cgroup certification and real upstream model certification are not included.
## Validation and acceptance criteria
Use an isolated worktree, disposable MySQL 8.4 databases, separate ports and Workspaces, and deterministic local model responses. Never run tool-effect tests in the user's actual Session or reset its database. Dry-run the E2E plan against global `qwen`, recording unsupported baseline paths honestly. Verify the built artifact with the test-engineer agent.
Exercise new/existing built-in/custom Sessions, all three execution modes, Plan reads and zero-effect refusals, approve/deny/cancel/expiry, stale CAS and receipt replay, owner/grant isolation, Plan Action refresh, and crash recovery before/after its decision/result. Verify approved execution still uses the frozen permission and old Actions do not release a new Plan. Regress team-member planning and native approvals.
Exercise text/image/file-only input, strict invalid shapes, MIME/encoding/signature and size boundaries, complete-input digest conflicts, old capability refusal, final model image support, Hook text updates, unknown-delivery refresh, transcript/snapshot/resync and subsequent history. Test a recoverable small prepared request and record the existing snapshot ceiling separately.
Run affected Java unit tests, API/SurfaceRegistry tests, SDK tests, full Java verification, real MySQL and matching Hosted recovery profiles; root build/typecheck/bundle, focused CLI/Web Shell tests, generated contract consistency, formatting and two clean self-audit passes. Independently review the full change before updating the local deployment. Report unavailable lanes explicitly.
## Open questions
None for this scope. Any implementation-driven correction must update both languages and the E2E plan before validation.
@@ -0,0 +1,79 @@
# Managed 会话 Plan 模式与输入附件
[English](2026-10-18-managed-plan-attachments.md) | [简体中文](2026-10-18-managed-plan-attachments.zh-CN.md)
## 问题与范围
Managed Web Shell 目前禁用了输入框的附件与 Plan 控件。Java API 和私有 Hosted prompt 路由仅接收文本,单独打开界面控件仍会丢失附件内容或无法准入。Hosted 团队成员的规划协议用于向组长提交计划,不等于公开会话中由用户审批计划。
为新建和已有 Managed 会话补齐 Plan 与有大小限制的内联附件。复用共享输入框、现有 owner 与 Workspace 授权、不可变 Turn 输入、Action 决策和恢复机制。执行权限与 Plan 保持独立。不改变普通 DAC/CLI 规划、团队成员规划、AgentDefinition 身份,也不因添加附件就写入 Workspace 文件系统。
## 当前状态
Java 将归一化输入存入 `ma_turn.input_json`,对完整输入计算摘要,并生成用于记录回放的用户 Item。Hosted 将已接收的 prompt 与 admission 存为不可变资源。模型回放历史时使用持久消息 parts,但当前首轮 prompt 仍被展平为文本。单个资源与预备模型快照均受 64 KiB 内联上限约束。
会话权限选择已有 owner 授权、Workspace OPERATOR 权限、空闲与恢复屏障、幂等回执、CAS 修订号和逐 Turn 冻结的执行策略。原生文件工具审批已有持久 v3 continuation;普通 Shell、MCP 和 Hook 批次并非都能通过该格式恢复。
## Plan 状态与公开 API
新增迁移 `V10__session_plan_mode.sql`,不重写已发布迁移。新增默认 false 的 `ma_session.plan_enabled` 和用于不可变快照、允许为空的 `ma_turn.plan_enabled`。复用 `approval_revision`,统一标识执行权限与 Plan 状态。切换 Plan 递增修订号,并将当前执行模式写入 `selected_approval_mode`,保持既有选择约束。Turn 准入在原会话行锁内复制模式、修订号与 Plan。
在现有 Web Shell 权限查询、变更、摘要和创建契约中增加 `planMode`,另行返回 `planModeSupported`。变更请求省略该字段时保留当前 Plan。定义初始权限不变。切换复用 owner 加 OPERATOR 要求及空闲屏障;活动 Turn 或待审批期间不能手动切换。空闲时手动退出不批准任何计划。修改执行权限保留 Plan,也不会代答 Action。
Plan 仅覆盖已有权限选择能力的公开 Workspace profile:`hosted-workspace-files/1`、`hosted-workspace-shell/1` 和 `/2`,支持内置及已保存定义。本次不扩展 child、team、workflow、unbound、MCP、Hook profile;保留其既有行为。
## Hosted Plan 执行与审批
增加能力 `plan-mode/1`,在不可变私有权限选择中增加可选的 `planMode: true`。false 和旧 Turn 省略该字段,保持旧 wire 身份。Java 在派发或推进携带 Plan 的活动 Turn 前必须检查能力。缺失能力时拒绝执行,绝不把 Plan Turn 降为执行模式。
工具 Turn 从 admission 和本 Turn 已持久批准的公开 `exit_plan_mode` Action 推导 Plan 覆盖层。仅允许 `read_file`、profile 已有的 `glob`,以及独立的公开 `exit_plan_mode` 控制调用。写入、编辑、Shell 命令、后台任务、child/workflow/team/message/MCP/skill 副作用均在执行前拒绝,`yolo` 和 Hook allow 不得绕过。Plan 阻止的 profile 内工具返回只读拒绝;底层 profile 以外的工具保留现有保护并使 Turn 失败。两条路径均不产生效果,也不退出 Plan。模型指令说明限制。首版明确不对远程 Shell 命令做只读分类。
公开退出工具接收非空 Markdown 计划,且必须独占批次。即使执行权限为 `yolo` 也必须询问用户。本 Turn 初始为 Plan 时始终保留该工具的稳定声明。Action 批准后仅解除本 Turn 的覆盖层,后续调用仍使用冻结的执行模式。因此 `default` 写入仍可能再次询问,`yolo` 才免普通工具审批。拒绝、过期和取消不授予执行权。批准后重复退出只返回已经结束规划的提示。
仅为精确的单个公开 Plan 退出控制调用扩展 v3 原生 continuation 的捕获和校验,保留普通 Shell/MCP/Hook 批次的原有限制。保存精确输入、工具声明、assistant 批次、模型轮次、Action 和 Runtime generation。该控制调用必须跳过 Runtime reserve、prepare、intent 和 execute。恢复时继续原 Action 并写入其结果,不重新询问,也不从内存标志猜测授权。保留既有 Runtime 获取、清理与 fencing 要求。
Java 投影有效且已批准的公开 Plan Action 时,验证其输入与 Turn 关联、`plan_enabled` 快照以及匹配的权限修订号。在同一 SQL 事务中清除会话 Plan 并递增会话修订号,Turn 快照保持不变。无论文件或 Shell profile,公开 Plan 退出都要求记录的会话 owner 且当前具备 OPERATOR 权限。迟到或无关 Action 不能清除较新的选择。刷新和进程重启因此能观察到已经完成的状态转换。
## 附件契约与模型输入
扩展封闭的公开输入联合类型:`input_file` 包含 `name`、`mime_type`、UTF-8 `text`,`input_image` 包含 `mime_type` 和 base64 `data`。保留 `input_text` 与旧 `text` 行为。拒绝混用或未知字段、空附件、任意 URL、宿主机路径、二进制文件、非法 base64 和不匹配的图片签名。文本附件使用保守的文本 MIME/扩展名白名单,并拒绝 NUL。图片支持 PNG、JPEG、WebP。PDF、Office、音频和视频不在范围内。
文件归一为带生成的非宿主 URI 的内联 ACP 文本资源,图片归一为 ACP image。语义摘要和 payload 摘要覆盖每个附件字节及名称、MIME。带附件请求的整份 canonical JSON 含文字最多 48 KiB,Java 与 Hosted 使用同一上限。Hosted 还精确检查真实用户消息是否满足既有 64 KiB 资源上限。不提高全局存储限制,不增加上传服务或存储表,不自动截断或省略内容。
声明 `input-attachments/1`;非文本派发必须具备该能力,纯文本兼容旧 Harness。已接收的不可变输入转换为同一组模型 parts,用于用户消息、首次生成、Hook 和恢复。文本资源带明确文件名边界,图片转为 `inlineData`。Hook 修改用户文字时保留附件 parts。应用最终定义 pin/会话模型选择后检查图片支持,包括保留的图片历史;纯文本模型明确失败,不能收到“图片不支持”的占位内容。
用户 Item 通过既有 JSON attributes 和资源引用保留 canonical 附件元数据及内容。实时事件、记录分页、snapshot/resync 和刷新重建相同的文件/图片展示。添加附件不写入 Workspace;之后模型发起的工具调用仍受 Plan 与执行权限约束。
## 输入框与重试行为
Managed 输入框显式启用共享添加菜单、Plan 控件和附件接入。新建会话在首个 Turn 前确定 Plan;已有会话以后台状态为准,切换复用现有可持久化的权限变更状态机与读回。计划审批使用现有覆盖层隐藏输入框,展示已提交计划,并用冻结执行权限标注批准操作。
允许只有附件的提交。在构建幂等 pending 请求前完成 Blob 编码与大小、MIME、UTF-8 校验。完整 canonical 输入与请求 key、会话身份共同持久化;刷新和投递未知重试复用原快照,不重读 Blob。收到准入成功后才清理输入框;错误保留草稿,不静默丢附件。pending 数据按 provider、tenant、actor 和 Session 隔离,保留现有不确定性处理。
## 影响范围
- Web Shell Managed provider 类型、生成的 Java API 类型、输入框接线、创建/pending 请求状态、Action 展示与记录投影。
- Java API/OpenAPI、输入校验、权限选择、Turn 准入、Action 投影与授权、读模型和能力校验。
- 共享 schema V10,以及 Server/Broker 初始化与校验。
- Java SDK 私有权限选择和 Hosted 客户端能力检查。
- Hosted prompt 解析、模型 parts/能力校验、公开 Plan 工具限制和精确单控制调用 continuation 恢复。
## 发布与限制
Java Harness 客户端构造时会固定能力快照。如果已经协商到不支持的 generation,恢复能力后需要重启控制面以构造新客户端;被阻断的 Turn 不会自动探测或绕过缺失能力。启用 Plan 前,停止或隔离共享 catalog 的旧 Server/Broker 二进制。备份并在隔离环境验证后,再应用 V9 到 V10 的增量迁移。同步部署兼容 Java、SDK、Harness 和 UI。旧二进制不能安全操作已经开启 Plan 的 catalog。保留已有数据、Workspace 绑定、凭据和不可变定义身份。
48 KiB 是单次输入预算,不保证无限会话。预备模型请求还包含历史、工具与系统指令,仍可能超过原有 64 KiB 快照预算。此时保留既有明确的恢复限制,不宣称完整恢复,也不静默删历史。本次不包括大文件/对象存储、远程 Shell 分类、其他输入框操作、跨 profile 继承、Linux Hook/cgroup 认证或真实上游模型认证。
## 验证与验收标准
使用隔离 worktree、一次性 MySQL 8.4 数据库、独立端口与 Workspace,以及确定性的本地模型响应。不在用户实际 Session 做工具副作用测试,不重置实际数据库。先用全局 `qwen` dry-run E2E 计划,如实记录不支持的基线路径,再由 test-engineer 验证本地构建产物。
覆盖新建/已有内置及自定义会话、三种执行权限、Plan 读取与零副作用拒绝、批准/拒绝/取消/过期、过期 CAS 与回执重放、owner/grant 隔离、Plan Action 刷新,以及决策/结果前后的崩溃恢复。确认批准后仍遵循冻结权限,旧 Action 不会解除新 Plan;回归团队成员规划与原生审批。
覆盖文本/图片/纯文件输入、非法形状、MIME/编码/签名与大小边界、完整输入摘要冲突、旧能力拒绝、最终模型图片能力、Hook 文字更新、投递未知后的刷新、记录/snapshot/resync 及后续历史。验证一个可恢复的小型预备请求,并单独记录原有快照上限。
运行受影响的 Java 单测、API/SurfaceRegistry 检查、SDK 测试、完整 Java 验证、真实 MySQL 和对应 Hosted 恢复 profile;运行根 build/typecheck/bundle、重点 CLI/Web Shell 单测、生成契约一致性、格式检查与两轮干净自审。更新本地部署前独立评审完整变更,明确报告不可用的验证通道。
## 未决问题
当前范围无未决问题。实施带来的修正必须先同步两种语言及 E2E 计划,再进行验证。
@@ -24,20 +24,41 @@ import {
resolveHostedAction,
} from './hosted-tool-approval.js';
import { findHostedApprovalContinuation } from './hosted-approval-continuation.js';
import { hostedPublicPlanActive } from './hosted-plan-mode.js';
afterEach(() => vi.restoreAllMocks());
it.each(
['allow', 'deny', 'cancelled', 'expired'].flatMap((decision) =>
[0, 16].flatMap((round) =>
[randomUUID(), 'mon_1:notify:3', `arun_${'a'.repeat(32)}:input`].map(
(promptId) => ({ decision, promptId, round }),
['allow', 'deny', 'cancelled', 'expired']
.flatMap((decision) =>
[0, 16].flatMap((round) =>
[randomUUID(), 'mon_1:notify:3', `arun_${'a'.repeat(32)}:input`].map(
(promptId) => ({
decision,
promptId,
round,
planMode: false,
profile: 'hosted-workspace-files/1',
}),
),
),
)
.concat(
['allow', 'deny', 'cancelled', 'expired'].flatMap((decision) =>
['hosted-workspace-files/1', 'hosted-workspace-shell/1'].map(
(profile) => ({
decision,
promptId: randomUUID(),
round: 1,
planMode: true,
profile,
}),
),
),
),
),
)(
'restores the original native batch and final Action under a replacement owner ($decision, $promptId, round=$round)',
async ({ decision, promptId, round }) => {
async ({ decision, promptId, round, planMode, profile }) => {
const root = await mkdtemp(path.join(tmpdir(), 'g3-step3-baseline-'));
const sessionKey = {
tenantId: 'tenant',
@@ -66,7 +87,7 @@ it.each(
Buffer.from(
JSON.stringify({
engine: 'managed',
toolProfile: 'hosted-workspace-files/1',
toolProfile: profile,
}),
),
),
@@ -95,11 +116,24 @@ it.each(
{
inputId: promptId,
turnId: promptId,
source: 'baseline-probe',
source: planMode ? 'hosted-harness' : 'baseline-probe',
contentRef: await resources.publish('managed-input', content),
admissionRef: await resources.publish(
'managed-admission',
Buffer.from('{}'),
Buffer.from(
JSON.stringify(
planMode
? {
promptId,
approvalSelection: {
mode: 'yolo',
revision: 1,
planMode: true,
},
}
: {},
),
),
),
deadline: null,
wakeReason: 'input',
@@ -151,14 +185,17 @@ it.each(
undefined,
undefined,
{
settings: { mode: 'default', timeoutMs: 60_000 },
settings: { mode: planMode ? 'yolo' : 'default', timeoutMs: 60_000 },
waiters: new HostedApprovalWaiters(),
},
{ profile, publicPlanMode: planMode },
);
const call = {
name: 'write_file',
name: planMode ? 'exit_plan_mode' : 'write_file',
callId: 'original-call',
args: { file_path: 'original.txt', content: 'one original batch' },
args: planMode
? { plan: '# Plan\nRead the files, then update original.txt.' }
: { file_path: 'original.txt', content: 'one original batch' },
isClientInitiated: false,
prompt_id: promptId,
};
@@ -260,6 +297,10 @@ it.each(
promptId,
});
expect(saved!.inputs[0].input).toEqual(call.args);
if (planMode)
expect(await hostedPublicPlanActive(replacement, promptId)).toBe(
decision !== 'allow',
);
expect(descriptor.v).toBe(3);
expect(prepare).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
@@ -24,6 +24,7 @@ import {
} from './hosted-tool-approval.js';
import { hostedRuntimeSessionId } from './hosted-workspace-broker.js';
import { readHostedTurnApprovalSelection } from './hosted-approval-selection.js';
const NATIVE_TOOLS = new Set(['read_file', 'write_file', 'edit', 'glob']);
@@ -159,11 +160,16 @@ export async function readHostedApprovalContinuation(
const runtimeIds = new Set<string>();
const partIndexes = new Set<number>();
const inputs = [];
const publicPlanExit =
plan.calls.length === 1 &&
plan.calls[0].call?.name === 'exit_plan_mode' &&
(await readHostedTurnApprovalSelection(session, promptId))?.planMode ===
true;
for (const [ordinal, item] of plan.calls.entries()) {
if (
Object.keys(item).length !== 11 ||
!item.call ||
!NATIVE_TOOLS.has(item.call.name) ||
!(NATIVE_TOOLS.has(item.call.name) || publicPlanExit) ||
item.call.prompt_id !== promptId ||
item.call.wasOutputTruncated ||
item.call.hadIncompleteArguments ||
@@ -228,6 +234,15 @@ export async function readHostedApprovalContinuation(
item.argsDigest !== `sha256:${managedToolDigest(payload.input)}`
)
throw new Error('Saved native request is invalid.');
if (
publicPlanExit &&
(Object.keys(payload.input).length !== 1 ||
typeof payload.input['plan'] !== 'string' ||
!payload.input['plan'].trim() ||
payload.input['plan'].length > 24000 ||
!isDeepStrictEqual(item.call.args, payload.input))
)
throw new Error('Saved public plan is invalid.');
const definition = JSON.parse(
(await session.resources.read(item.definitionRef)).toString('utf8'),
) as { name?: string };
@@ -80,6 +80,31 @@ describe('Hosted approval selection', () => {
initial,
);
});
it('keeps Plan independent of the execution policy and requires a positive snapshot revision', async () => {
for (const mode of ['default', 'auto-edit', 'yolo']) {
expect(
parseHostedApprovalSelection({ mode, revision: 1, planMode: true }),
).toEqual({ mode, revision: 1, planMode: true });
expect(
await hostedTurnApproval(
committed({ mode, revision: 1, planMode: true }),
'prompt',
{ mode: 'default', timeoutMs: 1000 },
),
).toEqual({ mode, timeoutMs: 1000 });
}
for (const planMode of [false, null, 'true', 1])
expect(() =>
parseHostedApprovalSelection({ mode: 'yolo', revision: 1, planMode }),
).toThrow();
expect(() =>
parseHostedApprovalSelection({
mode: 'yolo',
revision: 0,
planMode: true,
}),
).toThrow();
});
it('refuses mismatched prompts and conflicting revision-zero snapshots', async () => {
const initial = { mode: 'default' as const, timeoutMs: 1000 };
await expect(
@@ -14,6 +14,7 @@ import { assertManagedSessionDurableRef } from '@qwen-code/qwen-code-core/manage
export interface HostedApprovalSelection {
mode: HostedApprovalMode;
revision: number;
planMode?: true;
}
export function parseHostedApprovalSelection(
@@ -24,18 +25,21 @@ export function parseHostedApprovalSelection(
throw new Error('Invalid Hosted approval selection.');
const selection = value as Record<string, unknown>;
if (
Object.keys(selection).length !== 2 ||
Object.keys(selection).length !==
(selection['planMode'] === true ? 3 : 2) ||
(Object.hasOwn(selection, 'planMode') && selection['planMode'] !== true) ||
(selection['mode'] !== 'default' &&
selection['mode'] !== 'auto-edit' &&
selection['mode'] !== 'yolo') ||
typeof selection['revision'] !== 'number' ||
!Number.isSafeInteger(selection['revision']) ||
selection['revision'] < 0
selection['revision'] < (selection['planMode'] === true ? 1 : 0)
)
throw new Error('Invalid Hosted approval selection.');
return {
mode: selection['mode'] as HostedApprovalMode,
revision: selection['revision'],
...(selection['planMode'] === true ? { planMode: true as const } : {}),
};
}
@@ -52,6 +52,8 @@ describe('Hosted Harness private contract', () => {
'model-selection/1',
'approval-selection/1',
'agent-definition-approval-selection/1',
'plan-mode/1',
'input-attachments/1',
],
});
expect(Object.isFrozen(contract)).toBe(true);
@@ -76,6 +76,8 @@ export function createHostedHarnessContract(
'model-selection/1',
'approval-selection/1',
'agent-definition-approval-selection/1',
'plan-mode/1',
'input-attachments/1',
]),
});
}
@@ -25,6 +25,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadCliConfig } from '../config/config.js';
import {
HostedModelRoundLimitError,
HostedImageInputUnsupportedError,
runHostedHarnessTextTurn,
} from './hosted-harness-model.js';
import { HostedModelUnavailableError } from './hosted-agent-definition.js';
@@ -115,6 +116,7 @@ function config(
getModel: () => 'test-model',
getMaxSessionTurns: () => -1,
getContentGeneratorConfig: () => undefined,
getEffectiveInputModalities: () => ({ image: false }),
setUserMemory,
setPromptToolSnapshot,
setPromptAgentReachable,
@@ -167,6 +169,66 @@ function nativeInput(event: HookEventName): HookInput {
describe('Hosted Harness model boundary', () => {
beforeEach(() => vi.clearAllMocks());
it('passes attachments as real model parts while a prompt Hook only replaces the text', async () => {
const model = config([
{ type: LlmEventType.Content, value: 'done' },
{ type: LlmEventType.Finished },
]);
state.config['getEffectiveInputModalities'] = () => ({ image: true });
const hook = hostedHooks([HookEventName.UserPromptSubmit]);
hook.fire.mockImplementation(async (event) =>
event === HookEventName.UserPromptSubmit
? {
hookSpecificOutput: {
hookEventName: HookEventName.UserPromptSubmit,
updatedPrompt: 'updated text',
},
}
: undefined,
);
const attachmentParts = [
{ text: 'Attached file "notes.md": contents' },
{ inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' } },
];
await runHostedHarnessTextTurn({
...input,
attachmentParts,
hooks: hook.session,
});
expect(model.requests[0]).toEqual([
{ text: 'updated text' },
...attachmentParts,
]);
});
it.each([false, true])(
'refuses image input on a text-only selected model, including image history (history=%s)',
async (history) => {
const model = config([]);
const parts = [
{ inlineData: { mimeType: 'image/png', data: 'aW1hZ2U=' } },
];
await expect(
runHostedHarnessTextTurn({
...input,
...(history
? {
history: [
{
uuid: 'image-user',
type: 'user',
message: { role: 'user', parts },
} as ChatRecord,
],
}
: { attachmentParts: parts }),
}),
).rejects.toBeInstanceOf(HostedImageInputUnsupportedError);
expect(model.sendMessageStream).not.toHaveBeenCalled();
expect(model.shutdown).toHaveBeenCalled();
},
);
it.each([-1, 18])(
'continues beyond sixteen rounds with limit %s',
async (limit) => {
+39 -6
View File
@@ -74,6 +74,14 @@ export class HostedModelRoundLimitError extends Error {
}
}
export class HostedImageInputUnsupportedError extends Error {
constructor() {
super(
'The selected model does not support image input. Choose a model with image support or remove the image.',
);
}
}
export interface HostedHarnessTextDeltas {
delta(text: string): Promise<void>;
/**
@@ -128,6 +136,7 @@ export async function runHostedHarnessTextTurn(input: {
cwd: string;
history: readonly ChatRecord[];
prompt: string;
attachmentParts?: readonly Part[];
promptId: string;
signal: AbortSignal;
resumeFromToolResults?: readonly Part[];
@@ -152,7 +161,10 @@ export async function runHostedHarnessTextTurn(input: {
Partial<
Pick<
HostedWorkspaceToolTurn,
'resumeHookResults' | 'hookStopReason' | 'setModelRound'
| 'resumeHookResults'
| 'hookStopReason'
| 'setModelRound'
| 'planModeInstructions'
>
>;
workspaceContext?: { read(): string | undefined };
@@ -191,6 +203,7 @@ export async function runHostedHarnessTextTurn(input: {
? `# Agent instructions\n\n${input.agentInstructions}`
: undefined,
input.skills?.instructions(),
input.toolTurn?.planModeInstructions?.(),
context,
]
.filter(Boolean)
@@ -216,7 +229,13 @@ export async function runHostedHarnessTextTurn(input: {
: [],
);
if (!input.resumeFromToolResults && !input.resumeModel)
initialMessages.push({ role: 'user', parts: [{ text: input.prompt }] });
initialMessages.push({
role: 'user',
parts: [
...(input.prompt ? [{ text: input.prompt }] : []),
...(input.attachmentParts ?? []),
],
});
const initializationHooks: Array<{
fields: HookInput;
signal?: AbortSignal;
@@ -331,6 +350,16 @@ export async function runHostedHarnessTextTurn(input: {
if (model) throw new HostedModelUnavailableError(cause);
throw cause;
}
if (
[...initialMessages, ...(input.resumeModel?.prepared.history ?? [])].some(
(message) =>
message.parts?.some((part) =>
part.inlineData?.mimeType?.startsWith('image/'),
),
) &&
config.getEffectiveInputModalities().image !== true
)
throw new HostedImageInputUnsupportedError();
if (input.agentInstructions || input.skills)
await config.getLlmClient().refreshSystemInstruction();
await input.modelScope?.bindBudget(config.getTurnBudget(), input.prompt);
@@ -455,7 +484,10 @@ export async function runHostedHarnessTextTurn(input: {
config.getModel(),
input.signal,
)) ?? [...input.resumeFromToolResults])
: [{ text: effectivePrompt }];
: [
...(effectivePrompt ? [{ text: effectivePrompt }] : []),
...(input.attachmentParts ?? []),
];
let pendingToolResults =
input.resumeFromToolResults !== undefined ||
(input.resumeModel?.pendingToolResults ?? false);
@@ -496,9 +528,10 @@ export async function runHostedHarnessTextTurn(input: {
!previousTools ||
toolNames.size !== previousTools.size ||
[...toolNames].some((name) => !previousTools.has(name));
const contextAvailable = input.workspaceContext?.read();
const contextAvailable =
contextWithInstructions(input.workspaceContext?.read()) || undefined;
const contextChanged =
contextAvailable !== undefined &&
(contextAvailable !== undefined || injectedContext !== undefined) &&
contextAvailable !== injectedContext;
if (toolsChanged) {
config.setPromptToolSnapshot(toolNames);
@@ -507,7 +540,7 @@ export async function runHostedHarnessTextTurn(input: {
injectedTools = toolNames;
}
if (contextChanged) {
config.setUserMemory(contextWithInstructions(contextAvailable));
config.setUserMemory(contextAvailable ?? '');
injectedContext = contextAvailable;
}
// Config changes alone do not update the chat's cached instruction.
@@ -10,6 +10,7 @@ import {
hostedTurnApproval,
type HostedApprovalSelection,
} from './hosted-approval-selection.js';
import { parseHostedInput, readHostedInput } from './hosted-input.js';
import {
findHostedModelRequest,
findHostedCommittedModelOutput,
@@ -137,6 +138,7 @@ import {
} from './hosted-agent-definition.js';
import {
HostedModelRoundLimitError,
HostedImageInputUnsupportedError,
runHostedHarnessTextTurn,
} from './hosted-harness-model.js';
import { hostedToolProgress } from './hosted-tool-progress.js';
@@ -2788,6 +2790,8 @@ async function eventEnvelope(
const storeUnavailable =
event.payload['stopReason'] === 'managed_session_store_unavailable';
const roundLimit = event.payload['stopReason'] === 'model_round_limit';
const imageUnsupported =
event.payload['stopReason'] === 'image_input_unsupported';
return {
v: 1,
id: event.sequence,
@@ -2802,14 +2806,18 @@ async function eventEnvelope(
? 'model_unavailable'
: storeUnavailable
? 'managed_session_store_unavailable'
: roundLimit
? 'hosted_turn_limit_exceeded'
: 'hosted_turn_failed',
: imageUnsupported
? 'image_input_unsupported'
: roundLimit
? 'hosted_turn_limit_exceeded'
: 'hosted_turn_failed',
message: expired
? 'The Hosted Harness Turn exceeded its deadline.'
: roundLimit
? 'The Hosted Harness Turn reached its configured model round limit.'
: 'Hosted Harness turn failed.',
: imageUnsupported
? 'The selected model does not support image input. Choose a model with image support or remove the image.'
: roundLimit
? 'The Hosted Harness Turn reached its configured model round limit.'
: 'Hosted Harness turn failed.',
},
};
}
@@ -2941,6 +2949,9 @@ async function executeHostedTurn(
workflowLaunch?: HostedWorkflowLaunch,
): Promise<ChatRecord> {
const authority = session.managed.authority;
const promptInput = workflowLaunch
? { text, parts: [{ text }], attachments: [] }
: await readHostedInput(session.managed, promptId, text);
const harness = createManagedHarnessHandle(session.managed);
if (resumeModel) {
await harness.adoptPreparedContinuation(promptId, true);
@@ -3139,7 +3150,7 @@ async function executeHostedTurn(
if (!resumeFromToolResults && !resumeApproval && !resumeModel) {
const user = record(session, sessionId, 'user', parentUuid, {
daemonPromptId: promptId,
message: { role: 'user', parts: [{ text }] },
message: { role: 'user', parts: promptInput.parts },
});
await session.managed.sink.write(user);
parentUuid = user.uuid;
@@ -3222,6 +3233,13 @@ async function executeHostedTurn(
teams: session.teams,
commands: session.commands,
memberPlanMode: await ensureMemberPlanMode(session),
publicPlanMode:
(
await readHostedTurnApprovalSelection(
session.managed,
promptId,
)
)?.planMode === true,
},
)
: undefined;
@@ -3274,6 +3292,7 @@ async function executeHostedTurn(
cwd,
history,
prompt: text,
attachmentParts: promptInput.attachments,
promptId,
signal: abort.signal,
modelScope,
@@ -3335,6 +3354,11 @@ async function executeHostedTurn(
const outcome = settledTurnOutcome(abort);
state = outcome.state;
stopReason = outcome.stopReason;
if (
stopReason === 'error' &&
cause instanceof HostedImageInputUnsupportedError
)
stopReason = 'image_input_unsupported';
// A deadline expiry must keep naming the deadline: only a generic
// error outcome may be reclassified as a model-route failure.
if (
@@ -6303,6 +6327,18 @@ export function registerHostedHarnessSessionRoutes(
approvalSelection.mode !== session.approval.mode))
)
throw new Error('Approval selection is unavailable for this Session.');
if (
approvalSelection?.planMode &&
(![
'hosted-workspace-files/1',
'hosted-workspace-shell/1',
'hosted-workspace-shell/2',
].includes(session.toolProfile ?? '') ||
(session.childDepth ?? 0) !== 0 ||
session.mcp ||
session.hooks)
)
throw new Error('Public Plan mode is unavailable for this Session.');
} catch {
return error(res, 400, 'invalid_hosted_approval_selection');
}
@@ -6337,21 +6373,17 @@ export function registerHostedHarnessSessionRoutes(
return error(res, 400, 'invalid_hosted_prompt');
}
}
let promptInput: ReturnType<typeof parseHostedInput> | undefined;
try {
if (workflowLaunch === undefined) promptInput = parseHostedInput(prompt);
} catch {
return error(res, 400, 'invalid_hosted_prompt');
}
if (
typeof promptId !== 'string' ||
!HOSTED_UUID.test(promptId) ||
!Array.isArray(prompt) ||
prompt.length === 0 ||
(workflowLaunch === undefined &&
!prompt.every((block) => {
const item = object(block);
return (
item?.['type'] === 'text' &&
typeof item['text'] === 'string' &&
item['text'].length > 0 &&
Object.keys(item).length === 2
);
})) ||
typeof digest !== 'string' ||
!DIGEST.test(digest) ||
(deadlineMs !== undefined &&
@@ -6368,12 +6400,12 @@ export function registerHostedHarnessSessionRoutes(
// keeps the one-line summary while the full payload rides the input.
const text = workflowLaunch
? workflowLaunchSummaryText(workflowLaunch)
: prompt.map((block) => (block as { text: string }).text).join('\n');
: promptInput!.text;
const maxBytes = HTTP_MANAGED_SESSION_STORE_CONTRACT.maxInlineResourceBytes;
// A parent UUID is the largest possible parentUuid in the durable record.
const userRecord = record(session, req.params['id'], 'user', promptId, {
daemonPromptId: promptId,
message: { role: 'user', parts: [{ text }] },
message: { role: 'user', parts: promptInput?.parts ?? [{ text }] },
});
if (
Buffer.byteLength(JSON.stringify(prompt)) > maxBytes ||
@@ -8547,6 +8579,9 @@ export function registerHostedHarnessSessionRoutes(
teams: session.teams,
commands: session.commands,
memberPlanMode: await ensureMemberPlanMode(session),
publicPlanMode:
(await readHostedTurnApprovalSelection(session.managed, promptId))
?.planMode === true,
},
);
let state: 'completed' | 'cancelled' | 'error' = 'completed';
@@ -8782,6 +8817,11 @@ export function registerHostedHarnessSessionRoutes(
} catch (cause) {
if (cause instanceof HostedToolRecoveryRequiredError) throw cause;
state = abort.signal.aborted ? 'cancelled' : 'error';
if (
state === 'error' &&
cause instanceof HostedImageInputUnsupportedError
)
failureReason = 'image_input_unsupported';
if (state === 'error' && cause instanceof HostedModelUnavailableError)
failureReason = 'model_unavailable';
if (state === 'error') {
+113
View File
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
HOSTED_ATTACHMENT_INPUT_BYTES,
parseHostedInput,
} from './hosted-input.js';
const image = {
type: 'image',
mimeType: 'image/png',
data: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString('base64'),
};
const file = {
type: 'resource',
resource: {
uri: 'managed-input:///notes%201.md',
mimeType: 'text/markdown',
text: '你好\nsecond line',
},
};
describe('Hosted immutable attachment input', () => {
it('keeps real image parts and named text resources, including attachment-only input', () => {
const parsed = parseHostedInput([
{ type: 'text', text: 'Review' },
file,
image,
]);
expect(parsed.text).toBe('Review');
expect(parsed.parts[0]).toEqual({ text: 'Review' });
expect(parsed.parts[1]?.text).toContain('"notes 1.md" (text/markdown)');
expect(parsed.parts[1]?.text).toContain('你好\nsecond line');
expect(parsed.parts[2]).toEqual({
inlineData: { data: image.data, mimeType: 'image/png' },
});
expect(parseHostedInput([image]).parts).toEqual([parsed.parts[2]]);
expect(parseHostedInput([file]).text).toBe('');
});
it.each(
[
[],
[{ type: 'text', text: '' }],
[{ type: 'text', text: 'x', data: 'x' }],
[{ ...image, mimeType: 'image/jpeg' }],
[{ ...image, data: `${image.data}\n` }],
[{ ...image, data: '%%%=' }],
[{ type: 'image', mimeType: 'image/svg+xml', data: image.data }],
[{ ...file, resource: { ...file.resource, uri: 'file:///etc/passwd' } }],
[
{
...file,
resource: { ...file.resource, uri: 'https://example.org/file' },
},
],
[
{
...file,
resource: {
...file.resource,
uri: 'managed-input:///..%2Fsecret.txt',
},
},
],
[
{
...file,
resource: { ...file.resource, uri: 'managed-input:///%E0%A4%A' },
},
],
[
{
...file,
resource: { ...file.resource, mimeType: 'application/pdf' },
},
],
[{ ...file, resource: { ...file.resource, text: '\0binary' } }],
[{ ...file, resource: { ...file.resource, blob: 'ignored' } }],
].map((value) => ({ value })),
)('refuses unsupported or ambiguous input $value', ({ value }) => {
expect(() => parseHostedInput(value)).toThrow();
});
it('enforces the complete encoded input budget, including non-ASCII and surrounding text', () => {
const empty = [{ ...file, resource: { ...file.resource, text: '' } }];
const overhead = Buffer.byteLength(JSON.stringify(empty));
const within = [
{
...file,
resource: {
...file.resource,
text: 'x'.repeat(HOSTED_ATTACHMENT_INPUT_BYTES - overhead),
},
},
];
expect(Buffer.byteLength(JSON.stringify(within))).toBe(
HOSTED_ATTACHMENT_INPUT_BYTES,
);
expect(() => parseHostedInput(within)).not.toThrow();
expect(() =>
parseHostedInput([...within, { type: 'text', text: 'one byte too far' }]),
).toThrow('48 KiB');
expect(() =>
parseHostedInput([
{ ...file, resource: { ...file.resource, text: '界'.repeat(17000) } },
]),
).toThrow('48 KiB');
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
import { assertManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
export const HOSTED_ATTACHMENT_INPUT_BYTES = 48 * 1024;
const TEXT_EXTENSIONS = new Set([
'txt',
'md',
'markdown',
'csv',
'log',
'json',
'jsonl',
'yaml',
'yml',
'xml',
'html',
'htm',
'css',
'scss',
'js',
'jsx',
'ts',
'tsx',
'py',
'java',
'sql',
'sh',
'bash',
'rs',
'go',
'c',
'h',
'cpp',
'toml',
'ini',
'properties',
'svg',
]);
const TEXT_MIMES = new Set([
'application/json',
'application/xml',
'application/javascript',
'application/x-yaml',
'application/yaml',
'image/svg+xml',
]);
function object(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
export function parseHostedInput(value: unknown): {
text: string;
parts: Part[];
attachments: Part[];
} {
if (!Array.isArray(value) || !value.length || value.length > 100)
throw new Error('Invalid Hosted input.');
const texts: string[] = [];
const attachments: Part[] = [];
for (const block of value) {
const item = object(block);
if (
item?.['type'] === 'text' &&
Object.keys(item).length === 2 &&
typeof item['text'] === 'string' &&
item['text'].length > 0
) {
texts.push(item['text']);
continue;
}
if (
item?.['type'] === 'image' &&
Object.keys(item).length === 3 &&
typeof item['data'] === 'string' &&
typeof item['mimeType'] === 'string'
) {
const data = item['data'];
const mimeType = item['mimeType'];
if (!data || data.length > HOSTED_ATTACHMENT_INPUT_BYTES)
throw new Error('Invalid Hosted image.');
const bytes = Buffer.from(data, 'base64');
const valid =
mimeType === 'image/png'
? bytes.length >= 8 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
: mimeType === 'image/jpeg'
? bytes.length >= 3 &&
bytes[0] === 255 &&
bytes[1] === 216 &&
bytes[2] === 255
: mimeType === 'image/webp' &&
bytes.length >= 12 &&
bytes.toString('ascii', 0, 4) === 'RIFF' &&
bytes.toString('ascii', 8, 12) === 'WEBP';
if (!valid || bytes.toString('base64') !== data)
throw new Error('Invalid Hosted image.');
attachments.push({ inlineData: { data, mimeType } });
continue;
}
const resource = object(item?.['resource']);
if (
item?.['type'] === 'resource' &&
Object.keys(item).length === 2 &&
resource &&
Object.keys(resource).length === 3 &&
typeof resource['uri'] === 'string' &&
resource['uri'].startsWith('managed-input:///') &&
typeof resource['text'] === 'string' &&
resource['text'].length > 0 &&
!resource['text'].includes('\0') &&
typeof resource['mimeType'] === 'string'
) {
const name = decodeURIComponent(
resource['uri'].slice('managed-input:///'.length),
);
const mime = resource['mimeType'];
if (
!name.trim() ||
name.length > 255 ||
name === '.' ||
name === '..' ||
/[/\\\p{Cc}]/u.test(name) ||
!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(mime) ||
!(
mime.startsWith('text/') ||
TEXT_MIMES.has(mime) ||
(mime === 'application/octet-stream' &&
TEXT_EXTENSIONS.has(name.split('.').at(-1)!.toLowerCase()))
)
)
throw new Error('Invalid Hosted text attachment.');
attachments.push({
text: `Attached file ${JSON.stringify(name)} (${mime}):\n<attachment>\n${resource['text']}\n</attachment>`,
});
continue;
}
throw new Error('Invalid Hosted input block.');
}
if (
attachments.length &&
Buffer.byteLength(JSON.stringify(value)) > HOSTED_ATTACHMENT_INPUT_BYTES
)
throw new Error('Hosted attachment input exceeds 48 KiB.');
const text = texts.join('\n');
return {
text,
attachments,
parts: [...(text ? [{ text }] : []), ...attachments],
};
}
export async function readHostedInput(
session: ManagedSession,
promptId: string,
fallbackText: string,
): Promise<ReturnType<typeof parseHostedInput>> {
const accepted = session.authority
.eventsInSequenceRange(1, session.authority.committedSequence)
.findLast(
(event) =>
event.kind === 'input.accepted' &&
event.payload['inputId'] === promptId,
);
if (accepted?.payload['source'] !== 'hosted-harness')
return {
text: fallbackText,
parts: [{ text: fallbackText }],
attachments: [],
};
const ref = assertManagedSessionDurableRef(
accepted.payload['contentRef'],
'Hosted input',
);
if (
ref.kind !== 'managed-input' ||
ref.schemaVersion !== 1 ||
ref.byteLength > 64 * 1024
)
throw new Error('Invalid Hosted input reference.');
return parseHostedInput(
JSON.parse((await session.resources.read(ref)).toString('utf8')),
);
}
@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { FunctionDeclaration } from '@google/genai';
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
import { readHostedTurnApprovalSelection } from './hosted-approval-selection.js';
import {
readHostedActionOptions,
hostedActionAllowed,
} from './hosted-tool-approval.js';
import { readHostedApprovalContinuation } from './hosted-approval-continuation.js';
export const HOSTED_PUBLIC_PLAN_TOOL: FunctionDeclaration = {
name: 'exit_plan_mode',
description:
'Submit your complete Markdown plan for user approval, as the only call in a batch. Remain read-only until approval. Approval continues this Turn under its selected execution permission.',
parametersJsonSchema: {
type: 'object',
properties: { plan: { type: 'string', minLength: 1, maxLength: 24000 } },
required: ['plan'],
additionalProperties: false,
},
};
export const HOSTED_PUBLIC_PLAN_READS = new Set(['read_file', 'glob']);
export const HOSTED_PUBLIC_PLAN_REFUSAL =
'Plan mode is read-only: only read_file, glob 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,
promptId: string,
): Promise<boolean> {
if (!(await readHostedTurnApprovalSelection(session, promptId))?.planMode)
return false;
const events = session.authority.eventsInSequenceRange(
1,
session.authority.committedSequence,
);
for (const event of events) {
if (event.kind !== 'action.changed' || event.payload['state'] !== 'decided')
continue;
const action = session.authority.action(String(event.payload['requestId']));
if (!action) continue;
const options = await readHostedActionOptions(session, action);
if (
options.turnId !== promptId ||
options.toolName !== 'exit_plan_mode' ||
options.v !== 3 ||
!hostedActionAllowed(action, options.policyRevision)
)
continue;
const saved = await readHostedApprovalContinuation(
session,
options.continuationRef,
promptId,
);
if (
saved.plan.calls.length !== 1 ||
saved.plan.calls[0].call.name !== 'exit_plan_mode' ||
saved.plan.calls[0].call.callId !== options.functionCallId ||
saved.plan.calls[0].actionId !== action.requestId
)
throw new Error('Plan approval does not match its original call.');
return false;
}
return true;
}
@@ -39,6 +39,12 @@ import {
type HostedWorkspaceToolProfile,
} from './hosted-workspace-profiles.js';
import type { FunctionDeclaration, Part } from '@google/genai';
import {
HOSTED_PUBLIC_PLAN_TOOL,
HOSTED_PUBLIC_PLAN_READS,
HOSTED_PUBLIC_PLAN_REFUSAL,
hostedPublicPlanActive,
} from './hosted-plan-mode.js';
import type { ToolCallRequestInfo } from '@qwen-code/qwen-code-core/core/turn.js';
import type { DurableToolResultResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/resource-tool-result-store.js';
import {
@@ -1561,9 +1567,12 @@ interface HostedPreparedRequest {
message: boolean;
command: boolean;
skill: boolean;
planExit?: boolean;
}
export class HostedWorkspaceToolTurn {
private readonly publicPlanMode: boolean;
private planning: boolean;
private logicalRound = 0;
private nativeBatch?: {
messageId: string;
@@ -1685,6 +1694,7 @@ export class HostedWorkspaceToolTurn {
* `plan-mode="required"` preamble; its calls admit reads only.
*/
memberPlanMode?: boolean;
publicPlanMode?: boolean;
},
) {
this.foregroundOnly = extras?.foregroundOnly === true;
@@ -1711,6 +1721,21 @@ export class HostedWorkspaceToolTurn {
this.commands = this.foregroundOnly ? undefined : extras?.commands;
this.skills = extras?.skills;
this.memberPlanMode = extras?.memberPlanMode === true;
this.publicPlanMode = extras?.publicPlanMode === true;
this.planning = this.publicPlanMode;
if (
this.publicPlanMode &&
(this.hooks ||
this.mcp ||
this.memberPlanMode ||
this.childDepth !== 0 ||
![
'hosted-workspace-files/1',
'hosted-workspace-shell/1',
'hosted-workspace-shell/2',
].includes(this.profile ?? ''))
)
throw new Error('Public Plan mode is unavailable for this Session.');
this.publication =
publicationOrShell && 'owner' in publicationOrShell
? publicationOrShell
@@ -1762,6 +1787,9 @@ export class HostedWorkspaceToolTurn {
async declarations(signal: AbortSignal): Promise<FunctionDeclaration[]> {
signal.throwIfAborted();
this.planning =
this.publicPlanMode &&
(await hostedPublicPlanActive(this.session, this.promptId));
// #13753 I3: a snapshot child's read profile declares its two read
// tools and nothing else, whatever lanes, orchestration or MCP the
// Session has; the refusal of every other call rests on this alone.
@@ -1898,7 +1926,18 @@ export class HostedWorkspaceToolTurn {
: tool,
);
}
return this.advertised;
if (this.publicPlanMode) this.advertised.push(HOSTED_PUBLIC_PLAN_TOOL);
return this.planning
? this.advertised.filter(
(tool) =>
tool.name === 'exit_plan_mode' ||
HOSTED_PUBLIC_PLAN_READS.has(tool.name ?? ''),
)
: this.advertised;
}
planModeInstructions(): string {
return this.planning ? HOSTED_PUBLIC_PLAN_REFUSAL : '';
}
/** Which side of its lineage this Session's send_message addresses. */
@@ -2734,6 +2773,7 @@ export class HostedWorkspaceToolTurn {
message: false,
command: false,
skill: item.call.name === 'skill' && this.skills !== undefined,
planExit: this.publicPlanMode && item.call.name === 'exit_plan_mode',
});
}
this.logicalRound = plan.round;
@@ -2756,17 +2796,24 @@ export class HostedWorkspaceToolTurn {
): Promise<ManagedSessionDurableRef | undefined> {
const batch = this.nativeBatch;
const runtime = this.broker.runtime;
const singlePlanExit =
this.publicPlanMode &&
batch?.requests.length === 1 &&
batch.requests[0].planExit === true;
if (
!batch ||
batch.uncaptured ||
!runtime ||
isHostedWorkspaceShellProfile(this.profile) ||
(isHostedWorkspaceShellProfile(this.profile) && !singlePlanExit) ||
this.hooks ||
this.mcp ||
batch.requests.some(
(request) =>
!['read_file', 'write_file', 'edit', 'glob'].includes(
request.call.name,
!(
singlePlanExit ||
['read_file', 'write_file', 'edit', 'glob'].includes(
request.call.name,
)
) ||
// A call refused at the canonical-digest ceiling (#13803 D1) has
// no argument digest a restore could re-verify, and one refused
@@ -3014,10 +3061,31 @@ export class HostedWorkspaceToolTurn {
(HOSTED_TEAM_TOOL_NAMES.includes(call.name) ||
(call.name === 'team_plan_approval' &&
teamPlanAdmissionsEnabled()));
const planExit = this.publicPlanMode && call.name === 'exit_plan_mode';
// H4e-b3 decision 9: a run whose first input carried an unescaped
// plan-mode preamble admits reads only; every other call is
// refused before it runs, ahead of any per-tool arm below.
if (
this.planning &&
!planExit &&
!HOSTED_PUBLIC_PLAN_READS.has(call.name)
) {
validationError = HOSTED_PUBLIC_PLAN_REFUSAL;
input = { ...call.args };
} else if (planExit) {
input = { ...call.args };
if (!this.planning)
validationError = 'Plan mode already ended for this Turn.';
else if (
source.length !== 1 ||
Object.keys(input).length !== 1 ||
typeof input['plan'] !== 'string' ||
!input['plan'].trim() ||
input['plan'].length > 24000
)
validationError =
'Hosted exit_plan_mode requires a nonempty Markdown plan of at most 24000 characters and must be the only call in its batch.';
} else if (
this.memberPlanMode &&
!HOSTED_PLAN_MODE_READS.has(call.name) &&
!(
@@ -3609,6 +3677,7 @@ export class HostedWorkspaceToolTurn {
team,
command,
skill,
planExit,
};
});
};
@@ -4003,6 +4072,7 @@ export class HostedWorkspaceToolTurn {
request.message ||
request.team ||
request.command ||
request.planExit ||
request.skill ||
request.workflow
)
@@ -4442,6 +4512,29 @@ export class HostedWorkspaceToolTurn {
);
continue;
}
if (request.planExit) {
if (await hostedPublicPlanActive(this.session, this.promptId))
throw new HostedToolRecoveryRequiredError(
'The public plan has no durable approval.',
);
const converted = convertToFunctionResponse(
request.call.name,
request.call.callId,
[
{
text: 'The user approved the plan. Plan mode is now off for this Turn. Continue under the original execution permission; ordinary tool approvals still apply.',
},
],
);
if (!this.messageFitsInline('tool_result', converted, model))
throw new Error(
'Hosted Plan result exceeds the inline Session Store limit.',
);
await this.commit('tool_result', converted, model);
this.planning = false;
responses.push(...converted);
continue;
}
if (request.skill) {
const converted = convertToFunctionResponse(
request.call.name,
@@ -7252,8 +7345,17 @@ export class HostedWorkspaceToolTurn {
if (
refusals[index] ||
(request.call.name === 'skill' && this.skills !== undefined) ||
this.hookPermission.get(request.call.callId) === 'allow' ||
!hostedApprovalAsks(approval.settings, request.call.name, globProfile)
(!(
this.publicPlanMode &&
this.planning &&
request.call.name === 'exit_plan_mode'
) &&
(this.hookPermission.get(request.call.callId) === 'allow' ||
!hostedApprovalAsks(
approval.settings,
request.call.name,
globProfile,
)))
)
continue;
asked = true;
@@ -3,7 +3,11 @@ package com.alibaba.qwen.code.managedagent.api;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
@@ -12,8 +16,10 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class ApiModels {
private ApiModels() {
@@ -22,8 +28,38 @@ public final class ApiModels {
// The per-block cap is enforced in ManagedAgentService, counted in
// code points to match the published schema's maxLength; @Size counts
// UTF-16 units and would disagree on astral-plane text.
public record InputBlock(@NotBlank String type,
@NotBlank String text) {
@JsonDeserialize(using = InputBlockDeserializer.class)
public record InputBlock(@NotBlank String type, String text,
@JsonProperty("mime_type") String mimeType, String data, String name) {
public InputBlock(String type, String text) {
this(type, text, null, null, null);
}
}
public static final class InputBlockDeserializer extends StdDeserializer<InputBlock> {
public InputBlockDeserializer() { super(InputBlock.class); }
@Override
public InputBlock deserialize(JsonParser parser, DeserializationContext context) throws IOException {
JsonNode node = parser.getCodec().readTree(parser);
String type = node.path("type").asText();
Set<String> fields = switch (type) {
case "input_text", "text" -> Set.of("type", "text");
case "input_file" -> Set.of("type", "text", "name", "mime_type");
case "input_image" -> Set.of("type", "data", "mime_type");
default -> Set.of();
};
if (!node.isObject() || node.size() != fields.size()) {
throw context.weirdStringException(type, InputBlock.class, "Unknown input field or invalid input shape");
}
for (String field : fields) {
if (!node.path(field).isTextual()) {
throw context.weirdStringException(type, InputBlock.class, "Input fields must be strings");
}
}
return new InputBlock(type, node.path("text").textValue(), node.path("mime_type").textValue(),
node.path("data").textValue(), node.path("name").textValue());
}
}
public record CreateSessionRequest(
@@ -117,7 +153,8 @@ public final class ApiModels {
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean foregroundShell,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean modelChange,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean approvalModeChange,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean durableOperations) {
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean durableOperations,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean inputAttachments) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
@@ -256,19 +293,27 @@ public final class ApiModels {
String environmentId, String title,
@Size(max = 100) List<@Valid InputBlock> input,
Map<String, Object> metadata,
JsonNode workspace, String approvalMode) {
JsonNode workspace, String approvalMode, Boolean planMode) {
}
public record WebShellSessionOptionsRequest(@NotBlank @Size(max = 128) String agentId) {
}
public record WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes) {
public record WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes,
boolean planModeSupported, boolean inputAttachments) {
public WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes) {
this(approvalMode, availableApprovalModes, false, false);
}
}
public record WebShellApprovalSelection(String mode, long revision) {}
public record WebShellApprovalSelection(String mode, long revision, boolean planMode) {
public WebShellApprovalSelection(String mode, long revision) {
this(mode, revision, false);
}
}
public record WebShellSessionApproval(String approvalMode, long approvalRevision,
List<String> availableApprovalModes, boolean supported) {}
List<String> availableApprovalModes, boolean supported, boolean planMode, boolean planModeSupported) {}
public record WebShellSessionApprovalRequest(@NotBlank @Size(max = 128) String sessionId) {}
@@ -276,10 +321,14 @@ public final class ApiModels {
@NotBlank @Size(max = 128) String sessionId,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank String approvalMode,
@NotNull @Min(0) @Max(9_007_199_254_740_991L) Long expectedRevision) {}
@NotNull @Min(0) @Max(9_007_199_254_740_991L) Long expectedRevision, Boolean planMode) {}
public record WebShellApprovalChangeResult(String sessionId, String approvalMode,
long approvalRevision, boolean replayed) {}
long approvalRevision, boolean replayed, boolean planMode) {
public WebShellApprovalChangeResult(String sessionId, String approvalMode, long approvalRevision, boolean replayed) {
this(sessionId, approvalMode, approvalRevision, replayed, false);
}
}
public record WebShellModelOption(String modelId, String label) {}
@@ -432,7 +481,7 @@ public final class ApiModels {
WebShellWorkspace workspace,
WebShellSessionCapabilities capabilities, String approvalMode,
WebShellModelContext modelContext, WebShellModelSelection modelSelection,
Long approvalRevision, String initialApprovalMode) {
Long approvalRevision, String initialApprovalMode, boolean planMode, boolean planModeSupported) {
}
/** H6b: the definition fields a create or revise request carries. */
@@ -160,7 +160,7 @@ public class WebShellAgentController {
"unsupported_feature",
"Standalone Phase 1 has no environment templates.");
}
if (selection == null && request.approvalMode() != null) {
if (selection == null && (request.approvalMode() != null || request.planMode() != null)) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"unsupported_approval_mode",
"Approval mode selection requires a bound Workspace.");
@@ -176,7 +176,7 @@ public class WebShellAgentController {
: service.createWorkspaceSession(tenant.tenantId(),
tenant.requireActorId(), request.idempotencyKey(),
request.agentId(), null, request.title(), null,
request.input(), selection, request.approvalMode()));
request.input(), selection, request.approvalMode(), request.planMode()));
return ResponseEntity.accepted().body(admission);
}
@@ -277,7 +277,7 @@ public class WebShellAgentController {
HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
RequestIdFilter.useClientId(httpRequest, httpResponse, request.requestId());
return service.changeWebShellSessionApproval(tenant.tenantId(), tenant.actorId(), request.sessionId(),
request.idempotencyKey(), request.approvalMode(), request.expectedRevision());
request.idempotencyKey(), request.approvalMode(), request.expectedRevision(), request.planMode());
}
@PostMapping("/session-models/query")
@@ -15,6 +15,10 @@ public interface HarnessConnector extends AutoCloseable {
default boolean supportsModelSelection() { return false; }
default boolean supportsPlanMode() { return false; }
default boolean supportsInputAttachments() { return false; }
default boolean isWorkspaceFilesAvailable() {
return false;
}
@@ -447,6 +447,24 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
? supportsApprovalSelection() : supportsAgentDefinitionApprovalSelection();
}
@Override
public boolean supportsPlanMode() {
try {
return client().capabilities().getFeatures().contains("plan-mode/1");
} catch (RuntimeException unavailable) {
return false;
}
}
@Override
public boolean supportsInputAttachments() {
try {
return client().capabilities().getFeatures().contains("input-attachments/1");
} catch (RuntimeException unavailable) {
return false;
}
}
@Override
public boolean supportsModelSelection() {
try {
@@ -476,11 +494,18 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
// Revision zero uses the immutable definition on every Harness generation.
if (policy != null && policy.revision() > 0) {
if (supportsApprovalSelection(session)) {
builder.approvalSelection(policy.mode(), policy.revision());
if (policy.planMode() && !supportsPlanMode()) {
throw new IllegalStateException("Hosted Plan mode is unavailable");
}
builder.approvalSelection(policy.mode(), policy.revision(), policy.planMode());
} else {
throw new IllegalStateException("Hosted approval selection is unavailable");
}
}
if (input.stream().anyMatch(block -> "image".equals(block.get("type")) || "resource".equals(block.get("type")))
&& !supportsInputAttachments()) {
throw new IllegalStateException("Hosted input attachments are unavailable");
}
input.forEach(builder::addContent);
PromptReceipt receipt = client().submitTurn(builder.build());
return new Admission(receipt.getLastEventId(),
@@ -1258,23 +1283,34 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
}
private void requireApprovalSelectionSupport(SessionRecord session) {
var active = sessions.findActiveTurns(session.tenantId(), List.of(session.sessionId())).get(session.sessionId());
var activeTurn = active == null ? null : sessions.findTurn(session.tenantId(), session.sessionId(), active.turnId())
.orElseThrow(() -> new IllegalStateException("The active Turn is missing"));
if (activeTurn != null && activeTurn.input().stream().anyMatch(block -> "image".equals(block.get("type"))
|| "resource".equals(block.get("type"))) && !supportsInputAttachments()) {
throw new IllegalStateException("Hosted input attachments are unavailable");
}
if (!WorkspaceToolProfiles.supportsApprovalSelection(session)) {
return;
}
var selected = sessions.findApprovalSelections(session.tenantId(), List.of(session.sessionId())).get(session.sessionId());
if (selected != null && selected.planMode() && !supportsPlanMode()) {
throw new IllegalStateException("Hosted Plan mode is unavailable");
}
boolean changed = selected != null && selected.revision() > 0;
if (!supportsApprovalSelection(session) && (changed
|| WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && "yolo".equals(session.approvalMode()))) {
throw new IllegalStateException("Hosted approval selection is unavailable");
}
if (changed) {
var active = sessions.findActiveTurns(session.tenantId(), List.of(session.sessionId())).get(session.sessionId());
if (active != null) {
var turn = sessions.findTurn(session.tenantId(), session.sessionId(), active.turnId())
.orElseThrow(() -> new IllegalStateException("The active approval snapshot Turn is missing"));
if (sessions.findTurnApprovalSelection(session.tenantId(), session.sessionId(), turn.promptId()) == null) {
if (activeTurn != null) {
var policy = sessions.findTurnApprovalSelection(session.tenantId(), session.sessionId(), activeTurn.promptId());
if (policy == null) {
throw new IllegalStateException("The active Turn approval snapshot is missing");
}
if (policy.planMode() && !supportsPlanMode()) {
throw new IllegalStateException("Hosted Plan mode is unavailable");
}
}
}
}
@@ -302,7 +302,7 @@ public class ManagedActionService {
if (!text.equals(new String(bytes, StandardCharsets.UTF_8))) {
return null;
}
int end = Math.min(INPUT_PREVIEW_BYTES, bytes.length);
int end = Math.min("exit_plan_mode".equals(tool) ? 64 * 1024 : INPUT_PREVIEW_BYTES, bytes.length);
while (end < bytes.length && (bytes[end] & 0xc0) == 0x80) {
end--;
}
@@ -243,9 +243,13 @@ public class ManagedAgentService {
requireModelActor(actorId);
var options = store.workspaceSessionOptions(tenantId, agentId);
if ("qwen-code".equals(agentId) && store.workspaceShellEnabled() && harness.supportsApprovalSelection()) {
return new WebShellSessionOptions(options.approvalMode(), List.of("default", "auto-edit", "yolo"));
return new WebShellSessionOptions(options.approvalMode(), List.of("default", "auto-edit", "yolo"),
harness.supportsPlanMode(), harness.supportsInputAttachments());
}
return options;
return new WebShellSessionOptions(options.approvalMode(), options.availableApprovalModes(),
harness.supportsPlanMode() && harness.supportsAgentDefinitionApprovalSelection()
&& options.planModeSupported(),
harness.supportsInputAttachments());
}
public WebShellSessionModels queryWebShellSessionModels(String tenantId, String actorId,
@@ -280,16 +284,28 @@ public class ManagedAgentService {
return new WebShellSessionApproval(summary.approvalMode(),
summary.approvalRevision() == null ? 0 : summary.approvalRevision(),
supported ? List.of("default", "auto-edit", "yolo")
: summary.approvalMode() == null ? List.of() : List.of(summary.approvalMode()), supported);
: summary.approvalMode() == null ? List.of() : List.of(summary.approvalMode()), supported,
summary.planMode(), summary.planModeSupported());
}
public WebShellApprovalChangeResult changeWebShellSessionApproval(String tenantId, String actorId,
String sessionId, String key, String mode, long expectedRevision) {
return changeWebShellSessionApproval(tenantId, actorId, sessionId, key, mode, expectedRevision, null);
}
public WebShellApprovalChangeResult changeWebShellSessionApproval(String tenantId, String actorId,
String sessionId, String key, String mode, long expectedRevision, Boolean planMode) {
requireModelActor(actorId);
validateIdempotencyKey(key);
String digest = digests.digest(Map.of("sessionId", sessionId, "mode", mode, "expectedRevision", expectedRevision));
Map<String, Object> semantic = new LinkedHashMap<>(Map.of("sessionId", sessionId,
"mode", mode, "expectedRevision", expectedRevision));
if (planMode != null) {
semantic.put("planMode", planMode);
}
String digest = digests.digest(semantic);
return store.changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode,
expectedRevision, harness.supportsApprovalSelection(), harness.supportsAgentDefinitionApprovalSelection());
expectedRevision, harness.supportsApprovalSelection(), harness.supportsAgentDefinitionApprovalSelection(),
planMode, harness.supportsPlanMode());
}
private static void requireModelActor(String actorId) {
@@ -302,6 +318,14 @@ public class ManagedAgentService {
String actorId, String idempotencyKey, String agentId,
String agentRevision, String title, Map<String, Object> metadata,
List<InputBlock> blocks, WorkspaceSelection selection, String approvalMode) {
return createWorkspaceSession(tenantId, actorId, idempotencyKey, agentId, agentRevision,
title, metadata, blocks, selection, approvalMode, null);
}
public CommandAdmission createWorkspaceSession(String tenantId,
String actorId, String idempotencyKey, String agentId,
String agentRevision, String title, Map<String, Object> metadata,
List<InputBlock> blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode) {
validateIdempotencyKey(idempotencyKey);
if (actorId == null || actorId.isEmpty()) {
throw new ApiException(HttpStatus.UNAUTHORIZED,
@@ -323,6 +347,9 @@ public class ManagedAgentService {
if (approvalMode != null) {
semantic.put("approvalMode", approvalMode);
}
if (planMode != null) {
semantic.put("planMode", planMode);
}
String requestDigest = digests.digest(semantic);
String payloadDigest = input.isEmpty() ? null
: SubmitHarnessTurn.computePayloadDigest(input);
@@ -341,14 +368,16 @@ public class ManagedAgentService {
}
Admission admission;
try {
admission = approvalMode == null
admission = approvalMode == null && planMode == null
? store.insertWorkspaceSessionCommand(tenantId,
actorId, idempotencyKey, requestDigest, agentId,
agentRevision, effectiveTitle, input, payloadDigest, selection)
: store.insertWorkspaceSessionCommand(tenantId,
actorId, idempotencyKey, requestDigest, agentId,
agentRevision, effectiveTitle, input, payloadDigest,
selection, approvalMode, harness.supportsApprovalSelection());
selection, approvalMode, harness.supportsApprovalSelection()
&& ("qwen-code".equals(agentId) || harness.supportsAgentDefinitionApprovalSelection()),
planMode, harness.supportsPlanMode());
} catch (DuplicateKeyException error) {
admission = store.replayWorkspaceSessionCommand(tenantId,
actorId, idempotencyKey, requestDigest);
@@ -1221,6 +1250,8 @@ public class ManagedAgentService {
boolean keep = visible && retention;
boolean approvalExecutable = approvalSelection == null || approvalSelection.revision() == 0
|| supportsApprovalSelection(session);
approvalExecutable = approvalExecutable && (approvalSelection == null
|| !approvalSelection.planMode() || harness.supportsPlanMode());
return new WebShellSession(
session.sessionId(),
session.title(),
@@ -1243,11 +1274,14 @@ public class ManagedAgentService {
mayChangeCwd && approvalExecutable, foregroundShell(session),
visible && modelSelectionSupported && mayChangeCwd,
visible && approvalSupported && mayChangeCwd && !Boolean.FALSE.equals(foregroundShell(session)),
javaDurableAdmission == null ? null : visible),
javaDurableAdmission == null ? null : visible,
visible && maySubmit && harness.supportsInputAttachments()),
session.workspace() != null || session.agentDefinitionDigest() != null
? approvalSelection == null ? session.approvalMode() : approvalSelection.mode() : null,
modelContext, modelSelection, approvalSelection == null ? null : approvalSelection.revision(),
session.workspace() != null || session.agentDefinitionDigest() != null ? session.approvalMode() : null);
session.workspace() != null || session.agentDefinitionDigest() != null ? session.approvalMode() : null,
approvalSelection != null && approvalSelection.planMode(), visible && approvalSupported
&& mayChangeCwd && harness.supportsPlanMode() && store.supportsPlanMode(session));
}
private boolean supportsDelete(SessionRecord session, boolean retention) {
@@ -1772,7 +1806,7 @@ public class ManagedAgentService {
}
}
private static List<Map<String, Object>> input(List<InputBlock> blocks,
private List<Map<String, Object>> input(List<InputBlock> blocks,
boolean required) {
if (blocks == null || blocks.isEmpty()) {
if (required) {
@@ -1784,11 +1818,15 @@ public class ManagedAgentService {
List<Map<String, Object>> result = new ArrayList<>();
long totalChars = 0;
for (InputBlock block : blocks) {
if (block == null || !INPUT_TYPES.contains(block.type())
|| block.text() == null || block.text().isEmpty()) {
if (block != null && !INPUT_TYPES.contains(block.type())) {
result.add(ManagedInputAttachments.normalize(block));
continue;
}
if (block == null || block.text() == null || block.text().isEmpty()
|| block.mimeType() != null || block.name() != null || block.data() != null) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"unsupported_input",
"Phase 1 accepts non-empty text input only.");
"Expected non-empty text, a text file or an image.");
}
// Code points, matching the published schema's maxLength; a
// String.length() count would disagree on astral-plane text.
@@ -1810,7 +1848,13 @@ public class ManagedAgentService {
result.add(Map.of("type", "text", "text",
replaceLoneSurrogates(block.text())));
}
return List.copyOf(result);
var normalized = normalizeInput(result);
if (normalized.stream().anyMatch(block -> !"text".equals(block.get("type")))
&& digests.byteLength(normalized) > ManagedInputAttachments.MAX_INPUT_BYTES) {
throw new ApiException(HttpStatus.PAYLOAD_TOO_LARGE, "input_too_large",
"An input containing attachments must fit within 48 KiB of encoded JSON.");
}
return normalized;
}
/** The input the digest and the column both describe: an unpaired
@@ -0,0 +1,80 @@
package com.alibaba.qwen.code.managedagent.service;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock;
import org.springframework.http.HttpStatus;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
final class ManagedInputAttachments {
static final int MAX_INPUT_BYTES = 48 * 1024;
private static final Set<String> TEXT_EXTENSIONS = Set.of("txt", "md", "markdown", "csv", "log",
"json", "jsonl", "yaml", "yml", "xml", "html", "htm", "css", "scss", "js", "jsx",
"ts", "tsx", "py", "java", "sql", "sh", "bash", "rs", "go", "c", "h", "cpp",
"toml", "ini", "properties", "svg");
private static final Set<String> TEXT_MIMES = Set.of("application/json", "application/xml",
"application/javascript", "application/x-yaml", "application/yaml", "image/svg+xml");
private ManagedInputAttachments() {}
static Map<String, Object> normalize(InputBlock block) {
if ("input_file".equals(block.type())) {
String name = block.name();
String mime = block.mimeType();
String text = block.text();
if (name == null || name.isBlank() || name.length() > 255
|| name.equals(".") || name.equals("..") || name.matches("(?s).*[/\\\\\\p{Cc}].*")
|| mime == null || !mime.matches("[a-z0-9.+-]+/[a-z0-9.+-]+")
|| !(mime.startsWith("text/") || TEXT_MIMES.contains(mime)
|| (mime.equals("application/octet-stream") && TEXT_EXTENSIONS.contains(
name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT))))
|| text == null || text.isEmpty() || text.length() > MAX_INPUT_BYTES
|| text.indexOf('\0') >= 0 || block.data() != null) {
throw invalid();
}
String uri = "managed-input:///" + URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
return Map.of("type", "resource", "resource", Map.of("uri", uri, "mimeType", mime, "text", text));
}
if ("input_image".equals(block.type()) && block.text() == null && block.name() == null) {
String data = block.data();
String mime = block.mimeType();
if (data == null || data.isEmpty() || data.length() > MAX_INPUT_BYTES || mime == null) {
throw invalid();
}
byte[] bytes;
try {
bytes = Base64.getDecoder().decode(data);
} catch (IllegalArgumentException error) {
throw invalid();
}
if (!Base64.getEncoder().encodeToString(bytes).equals(data) || !imageSignature(mime, bytes)) {
throw invalid();
}
return Map.of("type", "image", "data", data, "mimeType", mime);
}
throw invalid();
}
private static boolean imageSignature(String mime, byte[] bytes) {
return switch (mime) {
case "image/png" -> bytes.length >= 8 && Arrays.equals(Arrays.copyOf(bytes, 8),
new byte[] {(byte) 137, 80, 78, 71, 13, 10, 26, 10});
case "image/jpeg" -> bytes.length >= 3 && bytes[0] == (byte) 255
&& bytes[1] == (byte) 216 && bytes[2] == (byte) 255;
case "image/webp" -> bytes.length >= 12 && new String(bytes, 0, 4, StandardCharsets.US_ASCII).equals("RIFF")
&& new String(bytes, 8, 4, StandardCharsets.US_ASCII).equals("WEBP");
default -> false;
};
}
private static ApiException invalid() {
return new ApiException(HttpStatus.BAD_REQUEST, "unsupported_input",
"Attachments require a UTF-8 text file or a valid PNG, JPEG or WebP image; URLs and host paths are unsupported.");
}
}
@@ -28,4 +28,12 @@ public class RequestDigests {
"Request cannot be canonicalized", error);
}
}
int byteLength(Object value) {
try {
return canonicalMapper.writeValueAsBytes(value).length;
} catch (JsonProcessingException error) {
throw new IllegalArgumentException("Request cannot be canonicalized", error);
}
}
}
@@ -163,6 +163,20 @@ public interface AgentStateStore {
throw new UnsupportedOperationException("Approval mode selection is unavailable");
}
@org.springframework.transaction.annotation.Transactional
default Admission insertWorkspaceSessionCommand(String tenantId, String actorId,
String idempotencyKey, String requestDigest, String agentId, String requestedRevision,
String title, List<Map<String, Object>> input, String payloadDigest,
WorkspaceSelection selection, String approvalMode, boolean approvalSelectionSupported,
Boolean planMode, boolean planModeSupported) {
if (Boolean.TRUE.equals(planMode)) {
throw new UnsupportedOperationException("Plan mode is unavailable");
}
return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest,
agentId, requestedRevision, title, input, payloadDigest, selection,
approvalMode, approvalSelectionSupported);
}
Admission replayWorkspaceSessionCommand(String tenantId, String actorId,
String idempotencyKey, String requestDigest);
@@ -594,6 +608,8 @@ public interface AgentStateStore {
default Map<String, WebShellApprovalSelection> findApprovalSelections(
String tenantId, List<String> sessionIds) { return Map.of(); }
default boolean supportsPlanMode(SessionRecord session) { return false; }
default Set<String> findApprovalOwners(String tenantId, String actorId,
List<String> sessionIds) { return Set.of(); }
@@ -606,6 +622,16 @@ public interface AgentStateStore {
default WebShellApprovalSelection findTurnApprovalSelection(String tenantId, String sessionId,
String promptId) { return null; }
default WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId,
String sessionId, String key, String digest, String mode, long expectedRevision,
boolean supported, boolean agentDefinitionSupported, Boolean planMode, boolean planModeSupported) {
if (planMode != null) {
throw new UnsupportedOperationException("Plan mode is unavailable");
}
return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision,
supported, agentDefinitionSupported);
}
default WebShellModelSelection findTurnModelSelection(String tenantId, String sessionId,
String promptId) { return null; }
@@ -146,6 +146,8 @@ public class ManagedActionStore {
continue;
}
ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId);
jdbc.queryForList("SELECT session_id FROM ma_session WHERE tenant_id=? AND session_id=? FOR UPDATE",
String.class, tenantId, sessionId);
JsonNode envelope = event.deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) envelope).remove("subject");
if (event.has("subject")) {
@@ -283,6 +285,29 @@ public class ManagedActionStore {
+ ManagedExtensionProjection.recordKey(
sessionId, "action_decision", id + ":" + decided.digest());
digest = decided.digest();
if ("allow".equals(body.path("optionId").asText()) && publicPlanExit(tenantId, sessionId, options)) {
JsonNode input = read(new String(resource(options.path("inputRef"), "managed-tool-input", resources).bytes(),
StandardCharsets.UTF_8));
JsonNode request = read(input.path("payloadJson").asText());
JsonNode continuation = read(new String(resource(options.path("continuationRef"),
"hosted-approval-continuation", resources).bytes(), StandardCharsets.UTF_8));
require(sessionId.equals(input.path("harnessSessionId").asText())
&& "exit_plan_mode".equals(request.path("toolName").asText())
&& request.path("input").size() == 1 && request.path("input").path("plan").isTextual()
&& !request.path("input").path("plan").asText().isBlank()
&& continuation.path("promptId").equals(options.path("turnId"))
&& continuation.path("calls").size() == 1
&& continuation.path("calls").get(0).path("call").path("name").asText().equals("exit_plan_mode")
&& continuation.path("calls").get(0).path("call").path("callId").equals(options.path("functionCallId"))
&& continuation.path("calls").get(0).path("inputRef").equals(options.path("inputRef"))
&& continuation.path("calls").get(0).path("actionId").asText().equals(id));
jdbc.update("UPDATE ma_session SET plan_enabled=FALSE, approval_revision=approval_revision+1,"
+ " version=version+1, gmt_modified=? WHERE tenant_id=? AND session_id=? AND plan_enabled=TRUE"
+ " AND approval_revision=(SELECT approval_revision FROM ma_turn WHERE tenant_id=?"
+ " AND session_id=? AND prompt_id=? AND plan_enabled=TRUE)",
event.path("occurredAt").asLong(), tenantId, sessionId, tenantId, sessionId,
options.path("turnId").asText());
}
} else {
require(decision.isNull());
}
@@ -345,6 +370,14 @@ public class ManagedActionStore {
}
}
private boolean publicPlanExit(String tenantId, String sessionId, JsonNode options) {
return options.path("v").asInt() == 3 && options.path("toolName").asText().equals("exit_plan_mode")
&& jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn t JOIN ma_session s ON s.tenant_id=t.tenant_id"
+ " AND s.session_id=t.session_id WHERE t.tenant_id=? AND t.session_id=? AND t.prompt_id=?"
+ " AND t.plan_enabled=TRUE AND s.session_kind='PUBLIC' AND s.parent_session_id IS NULL",
Integer.class, tenantId, sessionId, options.path("turnId").asText()) == 1;
}
public Optional<Action> find(String tenantId, String sessionId, String id) {
if (!id.matches("tool_approval_[0-9a-f]{32}")) {
return Optional.empty();
@@ -606,6 +639,11 @@ public class ManagedActionStore {
"action_not_found",
"The Action was not found."));
JsonNode o = action.options();
if (publicPlanExit(tenantId, sessionId, o)
&& (!admitted || !workspaceOperatorAdmits(tenantId, ownerRow, actorId))) {
throw new ApiException(HttpStatus.FORBIDDEN, "action_forbidden",
"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())
@@ -375,6 +375,18 @@ public class ManagedAgentStore implements AgentStateStore {
String requestedRevision, String title, List<Map<String, Object>> input,
String payloadDigest, WorkspaceSelection selection, String requestedApprovalMode,
boolean approvalSelectionSupported) {
return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest,
agentId, requestedRevision, title, input, payloadDigest, selection,
requestedApprovalMode, approvalSelectionSupported, null, false);
}
@Override
@Transactional
public Admission insertWorkspaceSessionCommand(String tenantId, String actorId,
String idempotencyKey, String requestDigest, String agentId,
String requestedRevision, String title, List<Map<String, Object>> input,
String payloadDigest, WorkspaceSelection selection, String requestedApprovalMode,
boolean approvalSelectionSupported, Boolean requestedPlanMode, boolean planModeSupported) {
if (!input.isEmpty() && !workspaceFilesEnabled) {
throw workspaceExecutionUnavailable();
}
@@ -388,6 +400,12 @@ public class ManagedAgentStore implements AgentStateStore {
}
AgentPin pin = selectApprovalMode(resolveAgentPin(tenantId, agentId,
requestedRevision, true), requestedApprovalMode, approvalSelectionSupported);
boolean planMode = Boolean.TRUE.equals(requestedPlanMode);
if (planMode && (!planModeSupported || !approvalSelectionSupported
|| pin.hooks() || !(WorkspaceToolProfiles.FILES.equals(pin.toolProfile())
|| WorkspaceToolProfiles.isPublicShell(pin.toolProfile())))) {
throw new ApiException(HttpStatus.CONFLICT, "unsupported_feature", "Plan mode is unavailable");
}
if (!input.isEmpty() && WorkspaceToolProfiles.isPublicShell(pin.toolProfile())
&& !workspaceShellEnabled) {
throw workspaceExecutionUnavailable();
@@ -417,7 +435,7 @@ public class ManagedAgentStore implements AgentStateStore {
}
return insertSession(tenantId, "CREATE_SESSION", idempotencyKey,
requestDigest, agentId, title, input, payloadDigest,
workspace, actorId, pin);
workspace, actorId, pin, null, planMode);
}
private boolean configuredStorage(String tenant, String storage) {
@@ -450,7 +468,7 @@ public class ManagedAgentStore implements AgentStateStore {
}
return insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest,
"qwen-code", title, List.of(), null, workspace, actorId,
new AgentPin(agentRevision, null, approvalMode, WorkspaceToolProfiles.INTERNAL_TEAM_LEAD));
new AgentPin(agentRevision, null, approvalMode, WorkspaceToolProfiles.INTERNAL_TEAM_LEAD, false));
}
@Override
@@ -908,7 +926,7 @@ public class ManagedAgentStore implements AgentStateStore {
* its digest and the approval mode and tool profile compiled from its content (D8c-1).
*/
private record AgentPin(String revision, String digest,
String approvalMode, String toolProfile) {
String approvalMode, String toolProfile, boolean hooks) {
}
private boolean isSshWorkspace(ContextBinding binding) {
@@ -928,7 +946,9 @@ public class ManagedAgentStore implements AgentStateStore {
String tenantId, String agentId) {
AgentPin pin = resolveAgentPin(tenantId, agentId, null, true);
return new WebShellSessionOptions(
pin.approvalMode(), availableApprovalModes(pin));
pin.approvalMode(), availableApprovalModes(pin),
!pin.hooks() && (WorkspaceToolProfiles.FILES.equals(pin.toolProfile())
|| WorkspaceToolProfiles.isPublicShell(pin.toolProfile())), false);
}
private static List<String> availableApprovalModes(AgentPin pin) {
@@ -938,6 +958,14 @@ public class ManagedAgentStore implements AgentStateStore {
: List.of("default", "auto-edit", "yolo");
}
@Override
public boolean supportsPlanMode(SessionRecord session) {
return WorkspaceToolProfiles.supportsApprovalSelection(session)
&& findChildLineage(session.tenantId(), session.sessionId()) == null
&& (session.agentDefinitionDigest() == null
|| !definitions.pinnedContent(session).containsKey("hook_catalog"));
}
private static AgentPin selectApprovalMode(AgentPin pin, String requested, boolean supported) {
if (requested == null) {
return pin;
@@ -948,7 +976,7 @@ public class ManagedAgentStore implements AgentStateStore {
throw new ApiException(HttpStatus.BAD_REQUEST, "unsupported_approval_mode",
"The selected approval mode is not available for this Agent.");
}
return new AgentPin(pin.revision(), pin.digest(), requested, pin.toolProfile());
return new AgentPin(pin.revision(), pin.digest(), requested, pin.toolProfile(), pin.hooks());
}
// Called only for a new admission; a retry has already replayed and
@@ -965,7 +993,7 @@ public class ManagedAgentStore implements AgentStateStore {
? (workspaceShellEnabled ? WorkspaceToolProfiles.SHELL
: WorkspaceToolProfiles.FILES)
: null;
return new AgentPin(agentRevision, null, approvalMode, toolProfile);
return new AgentPin(agentRevision, null, approvalMode, toolProfile, false);
}
if (definitions == null) {
throw ManagedAgentDefinitionStore.notFound();
@@ -978,7 +1006,7 @@ public class ManagedAgentStore implements AgentStateStore {
definitionModels);
return new AgentPin(Long.toString(revision.revision()),
revision.digest(), compiled.approvalMode(),
compiled.toolProfile());
compiled.toolProfile(), readMap(revision.definitionJson()).containsKey("hook_catalog"));
}
private ManagedAgentDefinitionStore.DefinitionRevision resolveDefinitionRevision(
@@ -1028,6 +1056,14 @@ public class ManagedAgentStore implements AgentStateStore {
String actorId,
AgentPin pin,
WorkspaceCsiRegistration registration) {
return insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title,
input, payloadDigest, resolved, actorId, pin, registration, false);
}
private Admission insertSession(String tenantId, String operation, String idempotencyKey,
String requestDigest, String agentId, String title, List<Map<String, Object>> input,
String payloadDigest, ResolvedBinding resolved, String actorId, AgentPin pin,
WorkspaceCsiRegistration registration, boolean planMode) {
ContextBinding workspace = resolved == null ? null
: resolved.binding();
long now = clock.millis();
@@ -1051,9 +1087,10 @@ public class ManagedAgentStore implements AgentStateStore {
+ " cwd_relative, context_config_ref,"
+ " context_revision, workspace_config_ref,"
+ " workspace_policy_ref, tool_profile,"
+ " creator_actor_key, owner_actor_key, runtime_request_key, approval_mode) VALUES"
+ " creator_actor_key, owner_actor_key, runtime_request_key, approval_mode,"
+ " plan_enabled, selected_approval_mode, approval_revision) VALUES"
+ " (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?,"
+ " ?, ?, ?, ?, ?, ?, ?, ?)",
+ " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId, sessionId, agentId, pin.revision(), pin.digest(),
title, now, now,
workspace == null ? null : workspace.getWorkspaceId(),
@@ -1068,7 +1105,8 @@ public class ManagedAgentStore implements AgentStateStore {
? CsiFilesRetirementProfile.PROFILE
: workspace == null ? null : pin.toolProfile(),
actorKey, actorKey,
runtimeRequestKey, workspace != null || pin.digest() != null ? pin.approvalMode() : "yolo");
runtimeRequestKey, workspace != null || pin.digest() != null ? pin.approvalMode() : "yolo",
planMode, planMode ? pin.approvalMode() : null, planMode ? 1 : 0);
jdbc.update("INSERT INTO ma_event_stream"
+ " (tenant_id, session_id, stream_kind, stream_id,"
+ " covered_sequence, gmt_modified) VALUES"
@@ -4070,10 +4108,10 @@ public class ManagedAgentStore implements AgentStateStore {
args.addAll(sessionIds);
Map<String, WebShellApprovalSelection> result = new HashMap<>();
jdbc.query("SELECT session_id, COALESCE(selected_approval_mode, approval_mode) AS mode,"
+ " approval_revision FROM ma_session WHERE tenant_id=? AND session_id IN ("
+ " approval_revision, plan_enabled FROM ma_session WHERE tenant_id=? AND session_id IN ("
+ placeholders(sessionIds.size()) + ") AND session_kind='PUBLIC'",
row -> { result.put(row.getString("session_id"), new WebShellApprovalSelection(
row.getString("mode"), row.getLong("approval_revision"))); }, args.toArray());
row.getString("mode"), row.getLong("approval_revision"), row.getBoolean("plan_enabled"))); }, args.toArray());
return result;
}
@@ -4094,7 +4132,7 @@ public class ManagedAgentStore implements AgentStateStore {
@Override
public WebShellApprovalSelection findTurnApprovalSelection(String tenantId, String sessionId,
String promptId) {
var rows = jdbc.query("SELECT t.approval_mode, t.approval_revision,"
var rows = jdbc.query("SELECT t.approval_mode, t.approval_revision, t.plan_enabled,"
+ " s.approval_revision AS selected_revision, s.agent_id, s.agent_definition_digest,"
+ " s.workspace_id, s.tool_profile FROM ma_turn t JOIN ma_session s"
+ " ON s.tenant_id=t.tenant_id AND s.session_id=t.session_id"
@@ -4111,7 +4149,8 @@ public class ManagedAgentStore implements AgentStateStore {
throw new ApiException(HttpStatus.CONFLICT, "approval_snapshot_missing", "The Turn has no approval snapshot");
}
// Legacy prompt replays must preserve the original absence of a selection.
return mode == null ? null : new WebShellApprovalSelection(mode, row.getLong("approval_revision"));
return mode == null ? null : new WebShellApprovalSelection(mode, row.getLong("approval_revision"),
row.getBoolean("plan_enabled"));
}, tenantId, sessionId, promptId);
if (rows.size() != 1) {
throw new ApiException(HttpStatus.CONFLICT, "turn_not_found", "The Turn was not found");
@@ -4124,6 +4163,15 @@ public class ManagedAgentStore implements AgentStateStore {
public WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId,
String sessionId, String key, String digest, String mode, long expectedRevision,
boolean supported, boolean agentDefinitionSupported) {
return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision,
supported, agentDefinitionSupported, null, false);
}
@Override
@Transactional
public WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId,
String sessionId, String key, String digest, String mode, long expectedRevision,
boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported) {
WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId);
SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId);
if (session.workspace() == null) {
@@ -4156,7 +4204,7 @@ public class ManagedAgentStore implements AgentStateStore {
}
Map<String, Object> result = readMap(replay.resultJson());
return new WebShellApprovalChangeResult(sessionId, (String) result.get("mode"),
((Number) result.get("revision")).longValue(), true);
((Number) result.get("revision")).longValue(), true, Boolean.TRUE.equals(result.get("planMode")));
}
if (!supported || session.agentDefinitionDigest() != null && !agentDefinitionSupported
|| !workspaceFilesEnabled || !WorkspaceToolProfiles.supportsApprovalSelection(session)
@@ -4166,27 +4214,34 @@ public class ManagedAgentStore implements AgentStateStore {
if (!WorkspaceToolProfiles.supportsApprovalMode(mode)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "unsupported_approval_mode", "The approval mode is unavailable");
}
boolean planMode = requestedPlanMode == null
? Boolean.TRUE.equals(jdbc.queryForObject("SELECT plan_enabled FROM ma_session WHERE tenant_id=? AND session_id=?",
Boolean.class, tenantId, sessionId)) : requestedPlanMode;
if (planMode && (!planModeSupported || !supportsPlanMode(session))) {
throw new ApiException(HttpStatus.CONFLICT, "unsupported_feature", "Plan mode is unavailable");
}
requireIdlePolicyChange(session);
long current = jdbc.queryForObject("SELECT approval_revision FROM ma_session WHERE tenant_id = ?"
+ " AND session_id = ?", Long.class, tenantId, sessionId);
if (current != expectedRevision || current >= 9_007_199_254_740_991L) {
if (current != expectedRevision || current >= 9_007_199_254_740_991L
|| planMode && current >= 9_007_199_254_740_990L) {
throw new ApiException(HttpStatus.CONFLICT, "approval_selection_conflict", "The approval revision changed");
}
long revision = current + 1;
long now = clock.millis();
jdbc.update("UPDATE ma_session SET selected_approval_mode = ?, approval_revision = ?, gmt_modified = ?,"
jdbc.update("UPDATE ma_session SET selected_approval_mode = ?, approval_revision = ?, plan_enabled = ?, gmt_modified = ?,"
+ " version = version + 1 WHERE tenant_id = ? AND session_id = ? AND approval_revision = ?",
mode, revision, now, tenantId, sessionId, current);
mode, revision, planMode, now, tenantId, sessionId, current);
ManagedReceipts.insert(jdbc, namespace, tenantId, scopeKind, scope, key, digest,
"SET_APPROVAL", actorId.getBytes(StandardCharsets.UTF_8), sessionId, null,
"COMPLETED", session.status(), true, now);
jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?",
writeJson(Map.of("mode", mode, "revision", revision)),
writeJson(Map.of("mode", mode, "revision", revision, "planMode", planMode)),
com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys.receiptKey(
namespace, tenantId, scopeKind, scope, key));
appendEvent(tenantId, sessionId, null, "session.approval.changed",
Map.of("mode", mode, "revision", revision), false, null, now);
return new WebShellApprovalChangeResult(sessionId, mode, revision, false);
Map.of("mode", mode, "revision", revision, "planMode", planMode), false, null, now);
return new WebShellApprovalChangeResult(sessionId, mode, revision, false, planMode);
}
private void requireIdlePolicyChange(SessionRecord session) {
@@ -5603,7 +5658,8 @@ public class ManagedAgentStore implements AgentStateStore {
itemId = StoreModels.inputItemId(event.turnId());
}
upsertItem(event, itemId, "message", "user", "completed",
Map.of());
input.stream().anyMatch(block -> "image".equals(block.get("type")) || "resource".equals(block.get("type")))
? Map.of("inputAttachments", input) : Map.of());
for (int index = 0; index < input.size(); index++) {
Map<String, Object> block = input.get(index);
String text = string(block.get("text"));
@@ -6048,7 +6104,7 @@ public class ManagedAgentStore implements AgentStateStore {
String payloadDigest, long now) {
jdbc.update("INSERT INTO ma_turn (tenant_id, session_id,"
+ " turn_id, prompt_id, input_json, payload_digest,"
+ " status, gmt_create, gmt_modified, model_id, model_revision, approval_mode, approval_revision) SELECT"
+ " status, gmt_create, gmt_modified, model_id, model_revision, approval_mode, approval_revision, plan_enabled) SELECT"
+ " ?, ?, ?, ?, ?, ?, 'ACCEPTED', ?, ?, model_id,"
+ " CASE WHEN model_id IS NULL THEN NULL ELSE model_revision END,"
+ " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)"
@@ -6060,11 +6116,17 @@ public class ManagedAgentStore implements AgentStateStore {
+ " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))"
+ " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1',"
+ " 'hosted-workspace-shell/1','hosted-workspace-shell/2')"
+ " THEN approval_revision ELSE NULL END"
+ " THEN approval_revision ELSE NULL END,"
+ " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)"
+ " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))"
+ " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1',"
+ " 'hosted-workspace-shell/1','hosted-workspace-shell/2')"
+ " THEN plan_enabled ELSE NULL END"
+ " FROM ma_session WHERE tenant_id = ? AND session_id = ?",
tenantId, sessionId, turnId, promptId, writeJson(input),
payloadDigest, now, now, "qwen-code".getBytes(StandardCharsets.UTF_8),
"qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8),
"qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8),
"qwen-code".getBytes(StandardCharsets.UTF_8), tenantId, sessionId);
}
@@ -23,6 +23,7 @@ public final class ManagedSchema {
public static final String MODEL_SELECTION = "/db/managed-schema/V5__session_model_selection.sql";
public static final String APPROVAL_SELECTION = "/db/managed-schema/V6__session_approval_selection.sql";
public static final String RUNTIME_SESSION_HARNESS = "/db/managed-schema/V9__runtime_session_harness.sql";
public static final String PLAN_MODE = "/db/managed-schema/V10__session_plan_mode.sql";
// Additive migrations over the published V1 baseline, in version order.
public static final String SSH_WORKSPACE =
"/db/managed-schema/V7__ssh_workspace_registration.sql";
@@ -32,7 +33,7 @@ public final class ManagedSchema {
new Migration(3, SESSION_LIST), new Migration(4, WORKSPACE_CREATION),
new Migration(5, MODEL_SELECTION), new Migration(6, APPROVAL_SELECTION),
new Migration(7, SSH_WORKSPACE), new Migration(8, TURN_COMMAND_OPERATIONS),
new Migration(9, RUNTIME_SESSION_HARNESS));
new Migration(9, RUNTIME_SESSION_HARNESS), new Migration(10, PLAN_MODE));
private static final int CURRENT = EXTENSIONS.getLast().version();
private record Migration(int version, String resource) {
@@ -120,7 +120,7 @@ public enum ManagedSchemaProjection {
+ "session_kind, tenant_key, session_key, session_scope_key, title_revision, "
+ "retirement_operation_id, retirement_generation, retired_at, recovery_protected, "
+ "resource_gc_completed, runtime_request_key, csi_guard, model_id, model_revision, "
+ "selected_approval_mode, approval_revision"),
+ "selected_approval_mode, approval_revision, plan_enabled"),
SESSION_HEAD(
"tenant_id, workspace_id, session_id, storage_version, state, writer_generation, "
+ "writer_id, writer_lease_until, lease_token_hash, journal_revision, "
@@ -176,7 +176,7 @@ public enum ManagedSchemaProjection {
+ "dispatch_lease_until, error_code, error_message, gmt_create, gmt_modified, "
+ "completed_at, version, retry_count, consecutive_failures, "
+ "budget_exempt_failures, retry_after, model_id, model_revision, "
+ "approval_mode, approval_revision");
+ "approval_mode, approval_revision, plan_enabled");
private final String columns;
@@ -0,0 +1,4 @@
ALTER TABLE ma_session ADD COLUMN plan_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ma_session ADD CONSTRAINT ck_ma_session_plan CHECK (plan_enabled = FALSE OR (selected_approval_mode IS NOT NULL AND approval_revision >= 1));
ALTER TABLE ma_turn ADD COLUMN plan_enabled BOOLEAN;
ALTER TABLE ma_turn ADD CONSTRAINT ck_ma_turn_plan CHECK (plan_enabled IS NULL OR (approval_mode IS NOT NULL AND approval_revision IS NOT NULL AND (plan_enabled = FALSE OR approval_revision >= 1)));
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "Qwen Managed Agent Public and WebShell API",
"version": "1.62.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
"version": "1.64.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
},
"servers": [
{
@@ -4713,20 +4713,17 @@
}
},
"InputBlock": {
"type": "object",
"additionalProperties": false,
"required": ["type", "text"],
"properties": {
"type": {
"type": "string",
"enum": ["input_text"]
"oneOf": [
{
"$ref": "#/components/schemas/InputTextBlock"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 1000000
{
"$ref": "#/components/schemas/InputFileBlock"
},
{
"$ref": "#/components/schemas/InputImageBlock"
}
}
]
},
"CreateSessionRequest": {
"type": "object",
@@ -4744,7 +4741,7 @@
"description": "Optional explicit revision; omitted resolves the latest revision at admission and pins it. For the built-in qwen-code agent it must equal the deployment revision."
},
"input": {
"description": "Each block adds to a 4,000,000-character aggregate admission budget per command (per-block 1,000,000-character cap applies). Admission ceiling only: a command whose serialized prompt or durable Session-store record exceeds the Hosted Harness's 64 KiB durable-record limit is accepted and then fails the Turn with hosted_harness_rejected.",
"description": "Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit and prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 48 KiB and requires input-attachments/1.",
"type": "array",
"maxItems": 100,
"items": {
@@ -4789,7 +4786,7 @@
"const": "agent.session.input.message"
},
"input": {
"description": "Each block adds to a 4,000,000-character aggregate admission budget per command (per-block 1,000,000-character cap applies). Admission ceiling only: a command whose serialized prompt or durable Session-store record exceeds the Hosted Harness's 64 KiB durable-record limit is accepted and then fails the Turn with hosted_harness_rejected.",
"description": "Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit and prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 48 KiB and requires input-attachments/1.",
"type": "array",
"minItems": 1,
"maxItems": 100,
@@ -5717,7 +5714,7 @@
"description": "The server enforces the bound in UTF-16 code units (Java String.length()), so an astral character counts as two: a title this schema's code-point maxLength accepts can still exceed the server's 256-unit bound and answer 400 invalid_title."
},
"input": {
"description": "Each block adds to a 4,000,000-character aggregate admission budget per command (per-block 1,000,000-character cap applies). Admission ceiling only: a command whose serialized prompt or durable Session-store record exceeds the Hosted Harness's 64 KiB durable-record limit is accepted and then fails the Turn with hosted_harness_rejected.",
"description": "Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit and prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 48 KiB and requires input-attachments/1.",
"type": "array",
"maxItems": 100,
"items": {
@@ -5734,7 +5731,11 @@
"approvalMode": {
"type": "string",
"enum": ["default", "auto-edit", "yolo"],
"description": "Optional policy for Workspace-bound creation only. Omission preserves the existing default and idempotency digest. Explicit selection is validated against the resolved Agent and tool profile and frozen in the Session; Shell refuses yolo and definitions retain their compiled policy."
"description": "Optional initial execution policy for Workspace creation, validated against the resolved Agent and profile. Omission preserves its default and idempotency digest. Independent of planMode."
},
"planMode": {
"type": "boolean",
"description": "Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state."
}
}
},
@@ -5758,7 +5759,7 @@
"format": "uuid"
},
"input": {
"description": "Each block adds to a 4,000,000-character aggregate admission budget per command (per-block 1,000,000-character cap applies). Admission ceiling only: a command whose serialized prompt or durable Session-store record exceeds the Hosted Harness's 64 KiB durable-record limit is accepted and then fails the Turn with hosted_harness_rejected.",
"description": "Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit and prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 48 KiB and requires input-attachments/1.",
"type": "array",
"minItems": 1,
"maxItems": 100,
@@ -6000,6 +6001,14 @@
"type": "string",
"enum": ["default", "auto-edit", "yolo"],
"description": "Immutable creation-time policy, used to confirm creation replay."
},
"planMode": {
"type": "boolean",
"description": "Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state."
},
"planModeSupported": {
"type": "boolean",
"description": "Optional deployment capability; missing means false."
}
}
},
@@ -6078,6 +6087,10 @@
"approvalModeChange": {
"type": "boolean",
"description": "The caller owns this eligible Workspace Session with current OPERATOR or OWNER access. Built-in qwen-code requires approval-selection/1; stored AgentDefinitions on exactly hosted-workspace-files/1 or hosted-workspace-shell/1,/2 also require agent-definition-approval-selection/1. Idle-state admission is checked separately. Missing means false."
},
"inputAttachments": {
"type": "boolean",
"description": "Optional deployment capability; missing means false."
}
}
},
@@ -9710,8 +9723,8 @@
"properties": {
"text": {
"type": "string",
"maxLength": 8192,
"description": "Exact prefix of the stored payloadJson ({toolName,input}), at most 8192 UTF-8 bytes ending at a complete code point. Truncated text may not be valid JSON."
"maxLength": 65536,
"description": "Exact UTF-8 prefix of payloadJson ({toolName,input}). Ordinary tools retain the 8192-byte preview. exit_plan_mode includes its complete input within the 64 KiB resource ceiling so a plan can be reviewed after refresh. Truncated ordinary previews may not be valid JSON."
},
"truncated": {
"type": "boolean",
@@ -9734,8 +9747,8 @@
"properties": {
"text": {
"type": "string",
"maxLength": 8192,
"description": "Exact prefix of the stored payloadJson ({toolName,input}), at most 8192 UTF-8 bytes ending at a complete code point. Truncated text may not be valid JSON."
"maxLength": 65536,
"description": "Exact UTF-8 prefix of payloadJson ({toolName,input}). Ordinary tools retain the 8192-byte preview. exit_plan_mode includes its complete input within the 64 KiB resource ceiling so a plan can be reviewed after refresh. Truncated ordinary previews may not be valid JSON."
},
"truncated": {
"type": "boolean",
@@ -10663,7 +10676,15 @@
"type": "string",
"enum": ["default", "auto-edit", "yolo"]
},
"description": "Effective modes for this Agent/profile. Stored definitions offer only their pinned mode; auto and plan are unsupported."
"description": "Execution policies available at creation. Stored definitions retain their initial policy; existing eligible Sessions may select a policy separately. Plan is an independent overlay."
},
"planModeSupported": {
"type": "boolean",
"description": "Optional deployment capability; missing means false."
},
"inputAttachments": {
"type": "boolean",
"description": "Optional deployment capability; missing means false."
}
}
},
@@ -10850,6 +10871,14 @@
},
"supported": {
"type": "boolean"
},
"planMode": {
"type": "boolean",
"description": "Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state."
},
"planModeSupported": {
"type": "boolean",
"description": "Optional deployment capability; missing means false."
}
}
},
@@ -10885,6 +10914,10 @@
"approvalMode": {
"type": "string",
"enum": ["default", "auto-edit", "yolo"]
},
"planMode": {
"type": "boolean",
"description": "Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state."
}
}
},
@@ -10912,6 +10945,10 @@
},
"replayed": {
"type": "boolean"
},
"planMode": {
"type": "boolean",
"description": "Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state."
}
}
},
@@ -10928,6 +10965,10 @@
"format": "int64",
"minimum": 0,
"maximum": 9007199254740991
},
"planMode": {
"type": "boolean",
"description": "Immutable Plan overlay for this approval selection."
}
}
},
@@ -11164,6 +11205,70 @@
"maxLength": 2048
}
}
},
"InputTextBlock": {
"type": "object",
"additionalProperties": false,
"required": ["type", "text"],
"properties": {
"type": {
"type": "string",
"enum": ["input_text", "text"]
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 1000000
}
}
},
"InputFileBlock": {
"type": "object",
"additionalProperties": false,
"required": ["type", "name", "mime_type", "text"],
"properties": {
"type": {
"type": "string",
"enum": ["input_file"]
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 255
},
"mime_type": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 49152
}
},
"description": "Inline UTF-8 text file. No host paths, URLs, binary or NUL. Conservative text MIME/extension validation; the complete canonical input containing attachments is limited to 48 KiB."
},
"InputImageBlock": {
"type": "object",
"additionalProperties": false,
"required": ["type", "mime_type", "data"],
"properties": {
"type": {
"type": "string",
"enum": ["input_image"]
},
"mime_type": {
"type": "string",
"enum": ["image/png", "image/jpeg", "image/webp"]
},
"data": {
"type": "string",
"minLength": 1,
"maxLength": 49152
}
},
"description": "Canonical base64 image with a matching signature. The complete canonical input containing attachments is limited to 48 KiB."
}
}
}
@@ -37,7 +37,7 @@ class BackfillWitnessesTest {
"SELECT version FROM flyway_schema_history"
+ " WHERE success = TRUE AND type = 'BASELINE'",
String.class))
.isEqualTo("9");
.isEqualTo("10");
}
@Test
@@ -2628,7 +2628,7 @@ class ManagedAgentApiContractTest {
.isEqualTo(session.get("last_event_id").asLong());
JsonNode webCapabilities = json("""
{"tasks":true,"artifacts":false,"actions":false,"workspaceTurns":false,"sessionClose":true,
"sessionArchive":true,"sessionUnarchive":true,"sessionDelete":true,"cwdChange":false,"modelChange":false,"approvalModeChange":false}
"sessionArchive":true,"sessionUnarchive":true,"sessionDelete":true,"cwdChange":false,"modelChange":false,"approvalModeChange":false,"inputAttachments":false}
""");
if (javaDurableAdmission != null) {
((com.fasterxml.jackson.databind.node.ObjectNode) webCapabilities)
@@ -2710,7 +2710,7 @@ class ManagedAgentApiContractTest {
assertThat(publishedKib * 1024)
.as("published durable-record limit is the enforced one")
.isEqualTo(ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES);
assertThat(CONTRACT.node("/components/schemas/InputBlock"
assertThat(CONTRACT.node("/components/schemas/InputTextBlock"
+ "/properties/text/maxLength").asLong())
.as("published per-block maxLength is the enforced"
+ " per-block cap")
@@ -98,6 +98,82 @@ class ManagedApprovalChangeTest {
.satisfies(row -> assertThat(row.approvalRevision()).isEqualTo(3));
}
@ParameterizedTest
@ValueSource(strings = {"hosted-workspace-files/1", "hosted-workspace-shell/1", "hosted-workspace-shell/2"})
void persistsPlanIndependentlyAndFreezesItWithTheTurnPolicy(String profile) throws Exception {
when(harness.supportsPlanMode()).thenReturn(true);
session = customSession(profile, "default");
var initial = store.requireSession(tenant, session);
var enabled = transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"enable-plan", "default", 0, true));
assertThat(enabled.planMode()).isTrue();
assertThat(enabled.approvalRevision()).isEqualTo(1);
assertThat(service.getWebShellSession(tenant, "actor", session).planMode()).isTrue();
assertThat(service.queryWebShellSessionApproval(tenant, "actor", session).planModeSupported()).isTrue();
assertThat(service.queryWebShellSessionOptions(tenant, "actor", initial.agentId()).planModeSupported()).isTrue();
assertThat(change("actor", "permission-only", "yolo", 1).planMode()).isTrue();
assertThat(transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"enable-plan", "default", 0, true)).replayed()).isTrue();
assertCode(() -> transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"stale", "default", 0, false)), "approval_selection_conflict");
var input = List.<Map<String, Object>>of(Map.of("type", "text", "text", "make a plan"));
var turn = transaction().execute(status -> store.insertTurnCommand(tenant, "SUBMIT_TURN", "plan-turn", "plan-digest",
session, input, SubmitHarnessTurn.computePayloadDigest(input)));
var prompt = store.findTurn(tenant, session, turn.turnId()).orElseThrow().promptId();
assertThat(store.findTurnApprovalSelection(tenant, session, prompt)).isEqualTo(new WebShellApprovalSelection("yolo", 2, true));
assertCode(() -> transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"active", "yolo", 2, false)), "turn_active");
when(harness.supportsPlanMode()).thenReturn(false);
assertThat(service.getWebShellSession(tenant, "actor", session).capabilities().workspaceTurns()).isFalse();
when(harness.supportsPlanMode()).thenReturn(true);
jdbc.update("UPDATE ma_turn SET status='COMPLETED' WHERE tenant_id=? AND turn_id=?", tenant, turn.turnId());
assertThat(transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"manual-off", "yolo", 2, false)).planMode()).isFalse();
assertThat(store.findTurnApprovalSelection(tenant, session, prompt).planMode()).isTrue();
assertThat(store.requireSession(tenant, session).agentDefinitionDigest()).isEqualTo(initial.agentDefinitionDigest());
assertThat(store.requireSession(tenant, session).approvalMode()).isEqualTo("default");
}
@Test
void initializesPlanBeforeTheFirstTurnAndReservesARevisionForItsApprovalExit() {
when(harness.supportsPlanMode()).thenReturn(true);
var created = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "initial-plan",
"qwen-code", null, null, null, List.of(new ApiModels.InputBlock("input_text", "plan this")),
new WorkspaceSelection("ws", "."), "yolo", true));
var turn = store.findTurn(tenant, created.sessionId(), created.turnId()).orElseThrow();
assertThat(store.findTurnApprovalSelection(tenant, created.sessionId(), turn.promptId()))
.isEqualTo(new WebShellApprovalSelection("yolo", 1, true));
assertThat(service.getWebShellSession(tenant, "actor", created.sessionId()).planMode()).isTrue();
jdbc.update("UPDATE ma_session SET selected_approval_mode='default',approval_revision=9007199254740990 WHERE tenant_id=? AND session_id=?", tenant, session);
assertCode(() -> transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"overflow-plan", "default", 9_007_199_254_740_990L, true)), "approval_selection_conflict");
when(harness.supportsPlanMode()).thenReturn(false);
assertCode(() -> transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"unsupported-plan", "default", 9_007_199_254_740_990L, true)), "unsupported_feature");
}
@Test
void keepsHookDefinitionsOutsidePublicPlanWhilePreservingApprovalChanges() throws Exception {
when(harness.supportsPlanMode()).thenReturn(true);
var definition = define("hosted-workspace-files/1", "default");
Map<String, Object> content = json.readValue(definition.definitionJson(),
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() { });
content.put("hook_catalog", Map.of("catalog_id", "review", "catalog_revision", 1,
"definition_digest", "a".repeat(64)));
var digest = new RequestDigests().digest(content);
definitions.update(tenant, "hook-definition", digest, definition.agentId(), digest.substring(7), json.writeValueAsString(content), 2);
session = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "hook-session",
definition.agentId(), null, null, null, List.of(), new WorkspaceSelection("ws", "."))).sessionId();
assertThat(service.queryWebShellSessionOptions(tenant, "actor", definition.agentId()).planModeSupported()).isFalse();
assertThat(service.getWebShellSession(tenant, "actor", session).planModeSupported()).isFalse();
assertCode(() -> transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session,
"hook-plan", "default", 0, true)), "unsupported_feature");
assertThat(change("actor", "hook-permission", "auto-edit", 0).approvalRevision()).isEqualTo(1);
assertCode(() -> transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "hook-create-plan",
definition.agentId(), null, null, null, List.of(), new WorkspaceSelection("ws", "."), "default", true)),
"unsupported_feature");
}
@Test
void explicitlyCreatesFullAccessAndCanReturnToAskingWithoutChangingInitialPolicy() {
var created = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "full-access",
@@ -291,10 +367,10 @@ class ManagedApprovalChangeTest {
var created = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "legacy-create",
definition.agentId(), null, null, null, input, new WorkspaceSelection("ws", ".")));
session = created.sessionId();
jdbc.update("UPDATE ma_turn SET status='COMPLETED',approval_mode=NULL,approval_revision=NULL WHERE tenant_id=? AND session_id=?",
jdbc.update("UPDATE ma_turn SET status='COMPLETED',approval_mode=NULL,approval_revision=NULL,plan_enabled=NULL WHERE tenant_id=? AND session_id=?",
tenant, session);
var submitted = transaction().execute(status -> service.submitTurn(tenant, "actor", "legacy-submit", session, input));
jdbc.update("UPDATE ma_turn SET status='COMPLETED',approval_mode=NULL,approval_revision=NULL WHERE tenant_id=? AND session_id=?",
jdbc.update("UPDATE ma_turn SET status='COMPLETED',approval_mode=NULL,approval_revision=NULL,plan_enabled=NULL WHERE tenant_id=? AND session_id=?",
tenant, session);
change("actor", "new-policy", "yolo", 0);
assertThat(transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "legacy-create",
@@ -316,7 +392,7 @@ class ManagedApprovalChangeTest {
var admitted = transaction().execute(status -> store.insertTurnCommand(tenant, "SUBMIT_TURN", "legacy", "legacy-digest",
session, input, SubmitHarnessTurn.computePayloadDigest(input)));
var turn = store.findTurn(tenant, session, admitted.turnId()).orElseThrow();
jdbc.update("UPDATE ma_turn SET approval_mode=NULL, approval_revision=NULL, status='COMPLETED'"
jdbc.update("UPDATE ma_turn SET approval_mode=NULL, approval_revision=NULL, plan_enabled=NULL, status='COMPLETED'"
+ " WHERE tenant_id=? AND turn_id=?", tenant, admitted.turnId());
assertThat(store.findTurnApprovalSelection(tenant, session, turn.promptId()))
.isNull();
@@ -27,7 +27,7 @@ import static org.mockito.Mockito.when;
class ManagedSchemaConfigurationTest {
@Test
void upgradesPublishedCatalogsWithoutChangingHistoryOrOrdinaryRows() throws Exception {
for (int version : new int[] {1, 2, 3, 4, 5, 6, 7}) {
for (int version : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9}) {
var source = source();
Flyway.configure()
.dataSource(source)
@@ -52,7 +52,7 @@ class ManagedSchemaConfigurationTest {
+ ManagedSchemaProjection.SESSION.columns().replace(
", runtime_request_key, csi_guard", "")
.replace(", model_id, model_revision", "")
.replace(", selected_approval_mode, approval_revision", "") + " FROM ma_session");
.replace(", selected_approval_mode, approval_revision, plan_enabled", "") + " FROM ma_session");
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(version);
assertThatThrownBy(() -> ManagedSchema.initialize(source))
.hasRootCauseMessage(
@@ -66,7 +66,7 @@ class ManagedSchemaConfigurationTest {
.locations(ManagedSchema.FLYWAY_LOCATION)
.load();
new ManagedSchemaConfiguration().freshManagedSchema(source).migrate(current);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(9);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(10);
assertThat(sessionListColumns(source)).containsExactly("tenant_id", "gmt_create", "session_id");
assertThat(
jdbc.queryForList(
@@ -95,9 +95,9 @@ class ManagedSchemaConfigurationTest {
@Test
void baselinesTheActualUnversionedCanonicalCatalog() throws Exception {
for (int version : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9}) {
for (int version : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) {
var source = source();
if (version < 9) {
if (version < 10) {
executeResource(source, ManagedSchema.RESOURCE);
if (version >= 2) {
executeResource(source, ManagedSchema.PRIVATE_AUTHORITY);
@@ -120,6 +120,9 @@ class ManagedSchemaConfigurationTest {
if (version >= 8) {
executeResource(source, ManagedSchema.TURN_COMMAND_OPERATIONS);
}
if (version >= 9) {
executeResource(source, ManagedSchema.RUNTIME_SESSION_HARNESS);
}
} else {
ManagedSchema.initialize(source);
}
@@ -138,7 +141,7 @@ class ManagedSchemaConfigurationTest {
+ " \"type\" = 'BASELINE'",
String.class))
.isEqualTo(Integer.toString(version));
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(9);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(10);
assertThat(sessionListColumns(source)).containsExactly("tenant_id", "gmt_create", "session_id");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session", Integer.class))
.isEqualTo(1);
@@ -185,7 +188,7 @@ class ManagedSchemaConfigurationTest {
+ ManagedSchemaProjection.SESSION.columns().replace(
", runtime_request_key, csi_guard", "")
.replace(", model_id, model_revision", "")
.replace(", selected_approval_mode, approval_revision", "") + " FROM ma_session");
.replace(", selected_approval_mode, approval_revision, plan_enabled", "") + " FROM ma_session");
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(2);
assertThat(sessionListColumns(source))
.containsExactly("tenant_id", "gmt_modified", "session_id");
@@ -201,7 +204,7 @@ class ManagedSchemaConfigurationTest {
.locations(ManagedSchema.FLYWAY_LOCATION)
.load());
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(9);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(10);
assertThat(sessionListColumns(source))
.containsExactly("tenant_id", "gmt_create", "session_id");
assertThat(
@@ -214,7 +217,7 @@ class ManagedSchemaConfigurationTest {
}
@Test
void upgradesPublishedV3ThroughV4V5V6V7V8ToV9WithoutChangingHistory() {
void upgradesPublishedV3ThroughV10WithoutChangingHistory() {
var source = source();
Flyway.configure().dataSource(source).locations(ManagedSchema.FLYWAY_LOCATION)
.target("3").load().migrate();
@@ -225,11 +228,11 @@ class ManagedSchemaConfigurationTest {
.hasRootCauseMessage("The canonical V3 catalog requires the application's V4 migration");
new ManagedSchemaConfiguration().freshManagedSchema(source).migrate(
Flyway.configure().dataSource(source).locations(ManagedSchema.FLYWAY_LOCATION).load());
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(9);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(10);
assertThat(jdbc.queryForList("SELECT * FROM \"flyway_schema_history\" WHERE \"version\" IN ('1', '2', '3') ORDER BY \"installed_rank\""))
.isEqualTo(published);
assertThat(jdbc.queryForList("SELECT \"version\" FROM \"flyway_schema_history\" WHERE \"version\" IS NOT NULL ORDER BY \"installed_rank\"", String.class))
.containsExactly("1", "2", "3", "4", "5", "6", "7", "8", "9");
.containsExactly("1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
ManagedSchema.validate(source);
}
@@ -267,7 +270,7 @@ class ManagedSchemaConfigurationTest {
ManagedSchema.initialize(source);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(9);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(10);
assertThat(sessionListColumns(source))
.containsExactly("tenant_id", "gmt_create", "session_id");
}
@@ -0,0 +1,121 @@
package com.alibaba.qwen.code.managedagent.service;
import com.alibaba.qwen.code.managedagent.api.ApiException;
import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock;
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
import com.alibaba.qwen.code.managedagent.store.ManagedSchemaJdbcTemplate;
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.time.Clock;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ManagedInputAttachmentTest {
private ManagedAgentService service;
private ManagedAgentStore store;
private static final String PNG = Base64.getEncoder().encodeToString(new byte[] {(byte) 137, 80, 78, 71, 13, 10, 26, 10});
@BeforeEach
void setup() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:input-attachments-" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
ManagedSchema.initialize(source);
var registry = mock(ManagedWorkspaceRegistry.class);
store = new ManagedAgentStore(new ManagedSchemaJdbcTemplate(source), new ObjectMapper(),
Clock.systemUTC(), events -> {}, registry, new ManagedAgentProperties());
var harness = mock(HarnessConnector.class);
when(harness.isAvailable()).thenReturn(true);
service = new ManagedAgentService(store, new RequestDigests(), mock(HarnessCoordinator.class), harness, registry);
}
@Test
void persistsTheCompleteInputAndProjectsTheSameAttachmentsAfterARead() {
var blocks = List.of(new InputBlock("input_text", "Review"),
new InputBlock("input_file", "你好\ncontents", "text/markdown", null, "notes 1.md"),
new InputBlock("input_image", null, "image/png", PNG, null));
var admission = service.createSession("tenant", "create", "qwen-code", null, null, null, blocks);
var input = store.findTurn("tenant", admission.sessionId(), admission.turnId()).orElseThrow().input();
assertThat(input).containsExactly(Map.of("type", "text", "text", "Review"),
Map.of("type", "resource", "resource", Map.of("uri", "managed-input:///notes%201.md",
"mimeType", "text/markdown", "text", "你好\ncontents")),
Map.of("type", "image", "data", PNG, "mimeType", "image/png"));
var replay = service.createSession("tenant", "create", "qwen-code", null, null, null, blocks);
assertThat(replay.replayed()).isTrue();
assertThat(replay.turnId()).isEqualTo(admission.turnId());
store.materializeNextBatch("tenant", admission.sessionId(), 100);
var items = service.listPublicItems("tenant", null, admission.sessionId(), 0, 100).data();
assertThat(items).singleElement().satisfies(item -> {
assertThat(item.attributes().get("inputAttachments")).isEqualTo(input);
assertThat(item.content()).singleElement().satisfies(part -> assertThat(part.text()).isEqualTo("Review"));
});
assertThatThrownBy(() -> service.createSession("tenant", "create", "qwen-code", null, null, null,
List.of(new InputBlock("input_file", "different contents", "text/markdown", null, "notes 1.md"))))
.isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode()).isEqualTo("idempotency_conflict"));
}
@Test
void acceptsFileOnlyInputAndEnforcesTheWholeEncodedBudget() {
var admitted = service.createSession("tenant", "file-only", "qwen-code", null, null, null,
List.of(new InputBlock("input_file", "const x = 1;", "application/octet-stream", null, "script.ts")));
assertThat(admitted.turnId()).isNotNull();
assertThatThrownBy(() -> service.createSession("tenant", "too-large", "qwen-code", null, null, null,
List.of(new InputBlock("input_text", "界".repeat(17000)),
new InputBlock("input_file", "small", "text/plain", null, "a.txt"))))
.isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode()).isEqualTo("input_too_large"));
}
@ParameterizedTest
@MethodSource("invalidBlocks")
void refusesUnsupportedShapesBeforeAnyAdmission(InputBlock block) {
assertThatThrownBy(() -> service.createSession("tenant", UUID.randomUUID().toString(), "qwen-code",
null, null, null, List.of(block))).isInstanceOf(ApiException.class);
}
static Stream<InputBlock> invalidBlocks() {
return Stream.of(new InputBlock("input_text", "hello", "text/plain", null, null),
new InputBlock("input_file", "%PDF", "application/pdf", null, "a.pdf"),
new InputBlock("input_file", "x", "text/plain", null, "../secret.txt"),
new InputBlock("input_file", "x", "text/plain", null, "a\nb.txt"),
new InputBlock("input_file", "x", "text/plain", null, "a\nb\nc.txt"),
new InputBlock("input_file", "x", "text/plain", null, "a\r\nb.txt"),
new InputBlock("input_file", "x", "text/plain", null, "\n../secret.txt\n"),
new InputBlock("input_file", "x\0y", "text/plain", null, "a.txt"),
new InputBlock("input_file", "", "text/plain", null, "a.txt"),
new InputBlock("input_file", "x", "text/plain", "extra", "a.txt"),
new InputBlock("input_image", null, "image/png", PNG + "\n", null),
new InputBlock("input_image", null, "image/jpeg", PNG, null),
new InputBlock("input_image", "extra", "image/png", PNG, null),
new InputBlock("input_image", null, "image/svg+xml", PNG, null));
}
@Test
void rejectsUnknownJsonFieldsRatherThanDroppingThem() {
assertThatThrownBy(() -> new ObjectMapper().readValue(
"{\"type\":\"input_text\",\"text\":\"hello\",\"url\":\"file:///secret\"}", InputBlock.class))
.hasMessageContaining("Unknown input field");
assertThatThrownBy(() -> new ObjectMapper().readValue(
"{\"type\":\"input_file\",\"text\":123,\"name\":\"a.txt\",\"mime_type\":\"text/plain\"}", InputBlock.class))
.hasMessageContaining("must be strings");
assertThatThrownBy(() -> new ObjectMapper().readValue(
"{\"type\":\"input_text\",\"text\":\"hello\",\"name\":null}", InputBlock.class))
.hasMessageContaining("invalid input shape");
}
}
@@ -19,7 +19,7 @@ class SshWorkspaceMySqlIT extends SshWorkspaceStoreTest {
.target("3").load().migrate();
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(3);
Flyway.configure().dataSource(source).locations(ManagedSchema.FLYWAY_LOCATION).load().migrate();
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(9);
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(10);
ManagedSchema.validate(source);
return source;
}
@@ -54,10 +54,15 @@ class WorkspaceCreationSchemaMySqlIT {
.migrate();
ManagedSchema.validate(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertThat(jdbc.queryForMap("SELECT * FROM ma_session"))
assertEquals(10, ManagedSchema.schemaVersion(source));
var upgradedSession = jdbc.queryForMap("SELECT * FROM ma_session");
assertThat(upgradedSession.remove("plan_enabled")).isEqualTo(false);
var upgradedTurn = jdbc.queryForMap("SELECT * FROM ma_turn");
assertThat(upgradedTurn).containsKey("plan_enabled");
assertThat(upgradedTurn.remove("plan_enabled")).isNull();
assertThat(upgradedSession)
.usingRecursiveComparison().isEqualTo(session);
assertThat(jdbc.queryForMap("SELECT * FROM ma_turn"))
assertThat(upgradedTurn)
.usingRecursiveComparison().isEqualTo(turn);
assertEquals(history, jdbc.queryForList("SELECT * FROM flyway_schema_history"
+ " WHERE installed_rank <= ? ORDER BY installed_rank", lastInstalledRank));
@@ -87,7 +92,7 @@ class WorkspaceCreationSchemaMySqlIT {
.migrate();
ManagedSchema.validate(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertEquals(10, ManagedSchema.schemaVersion(source));
assertThat(jdbc.queryForMap("SELECT * FROM ma_operation"))
.usingRecursiveComparison().isEqualTo(existing);
operation(jdbc, "created", "WORKSPACE_CREATE");
@@ -121,7 +126,7 @@ class WorkspaceCreationSchemaMySqlIT {
}
assertEquals(1, ManagedSchema.schemaVersion(source));
ManagedSchema.initialize(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertEquals(10, ManagedSchema.schemaVersion(source));
try (var connection = source.getConnection();
var statement = connection.createStatement();
var input = ManagedSchema.class.getResourceAsStream(ManagedSchema.RESOURCE)) {
@@ -135,6 +140,10 @@ class WorkspaceCreationSchemaMySqlIT {
statement.execute("ALTER TABLE ma_operation ADD "
+ baselineCheck(baseline, "ck_ma_operation_kind"));
statement.execute("DROP INDEX ix_ma_storage_owner_host ON ma_storage_owner");
statement.execute("ALTER TABLE ma_session DROP CONSTRAINT ck_ma_session_plan");
statement.execute("ALTER TABLE ma_turn DROP CONSTRAINT ck_ma_turn_plan");
statement.execute("ALTER TABLE ma_session DROP COLUMN plan_enabled");
statement.execute("ALTER TABLE ma_turn DROP COLUMN plan_enabled");
statement.execute("ALTER TABLE ma_session DROP CONSTRAINT ck_ma_session_approval_selection");
statement.execute("ALTER TABLE ma_turn DROP CONSTRAINT ck_ma_turn_approval_selection");
statement.execute("ALTER TABLE ma_session DROP COLUMN selected_approval_mode");
@@ -163,7 +172,7 @@ class WorkspaceCreationSchemaMySqlIT {
assertEquals(4, ManagedSchema.schemaVersion(source));
ManagedSchema.initialize(source);
ManagedSchema.validate(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertEquals(10, ManagedSchema.schemaVersion(source));
try (var connection = source.getConnection();
var statement = connection.createStatement()) {
receipt(
@@ -37,7 +37,7 @@ class ManagedSchemaTest {
+ "'prompt','[]','digest','COMPLETED',1,1)");
}
ManagedSchema.initialize(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertEquals(10, ManagedSchema.schemaVersion(source));
try (var connection = source.getConnection(); var statement = connection.createStatement()) {
try (var row = statement.executeQuery("SELECT model_id,model_revision FROM ma_session")) {
assertTrue(row.next());
@@ -65,7 +65,7 @@ class ManagedSchemaTest {
ManagedSchema.initialize(source);
ManagedSchema.initialize(source);
ManagedSchema.validate(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertEquals(10, ManagedSchema.schemaVersion(source));
try (var connection = source.getConnection()) {
assertEquals(
ManagedSchemaContract.TABLES, ManagedSchemaCatalog.tables(connection).keySet());
@@ -89,7 +89,7 @@ class ManagedSchemaTest {
() -> receipt(connection, "unsupported", "WORKSPACE_CREATE"));
}
ManagedSchema.initialize(source);
assertEquals(9, ManagedSchema.schemaVersion(source));
assertEquals(10, ManagedSchema.schemaVersion(source));
try (var connection = source.getConnection();
var statement = connection.createStatement()) {
receipt(connection, "new", "WORKSPACE_CREATE");
@@ -287,6 +287,14 @@ public final class HostedHarnessClient implements AutoCloseable {
&& !capabilities().getFeatures().contains("approval-selection/1")) {
throw new DaemonException("Hosted approval selection is unavailable");
}
if (request.getApprovalSelection() != null && Boolean.TRUE.equals(request.getApprovalSelection().get("planMode"))
&& !capabilities().getFeatures().contains("plan-mode/1")) {
throw new DaemonException("Hosted Plan mode is unavailable");
}
if (request.getPromptContent().stream().anyMatch(block -> List.of("image", "resource").contains(block.get("type")))
&& !capabilities().getFeatures().contains("input-attachments/1")) {
throw new DaemonException("Hosted input attachments are unavailable");
}
ActivePrompt candidate = new ActivePrompt(request.getPromptId(),
request.getPayloadDigest(), session.getHarnessClientId(),
request.getModelSelection(), request.getApprovalSelection());
@@ -138,6 +138,8 @@ public final class SubmitHarnessTurn {
Map<String, Object> getApprovalSelection() { return approvalSelection; }
List<Map<String, Object>> getPromptContent() { return promptContent; }
Map<String, Object> toJson() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("prompt", wirePromptContent);
@@ -216,12 +218,19 @@ public final class SubmitHarnessTurn {
}
public Builder approvalSelection(String mode, long revision) {
return approvalSelection(mode, revision, false);
}
public Builder approvalSelection(String mode, long revision, boolean planMode) {
if (mode == null || !List.of("default", "auto-edit", "yolo").contains(mode)
|| revision < 0 || revision > 9_007_199_254_740_991L) {
|| revision < 0 || revision > 9_007_199_254_740_991L || planMode && revision == 0) {
throw new IllegalArgumentException("Invalid Hosted approval selection");
}
approvalSelection = Collections.unmodifiableMap(new LinkedHashMap<>(
Map.of("mode", mode, "revision", revision)));
Map<String, Object> fields = new LinkedHashMap<>(Map.of("mode", mode, "revision", revision));
if (planMode) {
fields.put("planMode", true);
}
approvalSelection = Collections.unmodifiableMap(fields);
return this;
}
@@ -0,0 +1,37 @@
package com.alibaba.qwen.code.daemon;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SubmitHarnessTurnTest {
private SubmitHarnessTurn.Builder turn() {
return SubmitHarnessTurn.builder().session(new HarnessSessionRef("22222222-2222-4222-8222-222222222222",
"client", "11111111-1111-4111-8111-111111111111", "/control", null, null, null))
.promptId("33333333-3333-4333-8333-333333333333").addText("plan this")
.payloadDigest(SubmitHarnessTurn.computePayloadDigest(List.of(Map.of("type", "text", "text", "plan this"))));
}
@Test
void preservesLegacyWireShapeAndExplicitlyCarriesOnlyEnabledPlanSnapshots() {
assertEquals(Map.of("mode", "yolo", "revision", 1L), turn().approvalSelection("yolo", 1).build().getApprovalSelection());
assertEquals(Map.of("mode", "yolo", "revision", 1L), turn().approvalSelection("yolo", 1, false).build().getApprovalSelection());
assertEquals(Map.of("mode", "yolo", "revision", 1L, "planMode", true),
turn().approvalSelection("yolo", 1, true).build().getApprovalSelection());
assertThrows(IllegalArgumentException.class, () -> turn().approvalSelection("yolo", 0, true));
}
@Test
void hashesEveryInlineAttachmentByteRatherThanOnlyThePromptText() {
var first = List.<Map<String, Object>>of(Map.of("type", "resource", "resource",
Map.of("uri", "managed-input:///a.txt", "mimeType", "text/plain", "text", "first")));
var next = List.<Map<String, Object>>of(Map.of("type", "resource", "resource",
Map.of("uri", "managed-input:///a.txt", "mimeType", "text/plain", "text", "second")));
assertNotEquals(SubmitHarnessTurn.computePayloadDigest(first), SubmitHarnessTurn.computePayloadDigest(next));
}
}
@@ -46,7 +46,14 @@ import { useManagedTaskCancel } from './use-managed-task-cancel';
import type {
ManagedAgentProvider,
ManagedAgentSessionSummary,
ManagedInputBlock,
} from './managed-agent-provider';
import type { PromptImage, PromptFile } from '../../adapters/promptTypes';
import {
encodeManagedInput,
isManagedInput,
restoreManagedAttachments,
} from './managed-input';
interface PendingPrompt {
idempotencyKey: string;
@@ -54,6 +61,7 @@ interface PendingPrompt {
draftText?: string;
sessionId?: string;
cwd?: string;
input?: ManagedInputBlock[];
}
function readPending(key: string): PendingPrompt | undefined {
@@ -63,7 +71,8 @@ function readPending(key: string): PendingPrompt | undefined {
const pending = value as Record<string, unknown>;
if (
typeof pending['idempotencyKey'] !== 'string' ||
typeof pending['text'] !== 'string'
typeof pending['text'] !== 'string' ||
(pending['input'] !== undefined && !isManagedInput(pending['input']))
)
return undefined;
if (
@@ -84,12 +93,16 @@ function readPending(key: string): PendingPrompt | undefined {
}
}
function persistPending(key: string, value: PendingPrompt | undefined): void {
function persistPending(
key: string,
value: PendingPrompt | undefined,
): boolean {
try {
if (value) sessionStorage.setItem(key, JSON.stringify(value));
else sessionStorage.removeItem(key);
return true;
} catch {
// The in-memory attempt still preserves retries when storage is disabled.
return false;
}
}
@@ -216,6 +229,7 @@ function ManagedSessionsContent({
readPending(pendingKey),
);
const pendingRef = useRef(pending);
const encoding = useRef(false);
const focusComposer = useRef<{ sessionId: string | undefined } | undefined>(
undefined,
);
@@ -403,12 +417,18 @@ function ManagedSessionsContent({
}
}
async function submit(submittedText = text, commit?: ComposerSubmitCommit) {
async function submit(
submittedText = text,
commit?: ComposerSubmitCommit,
images?: PromptImage[],
files?: PromptFile[],
) {
const abort = lifetime.current;
if (
!abort ||
abort.signal.aborted ||
busy ||
encoding.current ||
cwd.isBlocked() ||
modelChange.isBlocked() ||
approvalChange.isBlocked()
@@ -418,20 +438,37 @@ function ManagedSessionsContent({
let attempt = pendingRef.current;
if (!attempt) {
if (
!submittedText.trim() ||
(!submittedText.trim() && !images?.length && !files?.length) ||
(sessionId && !detail.summary?.capabilities.canSend)
)
return;
let input: ManagedInputBlock[];
encoding.current = true;
setBusy(true);
try {
input = await encodeManagedInput(submittedText, images, files);
} catch (failure) {
setError(failure instanceof Error ? failure.message : String(failure));
return;
} finally {
encoding.current = false;
setBusy(false);
}
if (abort.signal.aborted || selection.current !== sessionId) return;
attempt = {
idempotencyKey: managedRequestId(),
text: submittedText,
draftText: composerRef.current?.getText(),
sessionId,
cwd: workspaceCwd,
...(images?.length || files?.length ? { input } : {}),
};
if (!persistPending(pendingKey, attempt)) {
setError(t('managed.workspaceStorageUnavailable'));
return;
}
pendingRef.current = attempt;
setPending(attempt);
persistPending(pendingKey, attempt);
composerCommit.current = commit;
}
setBusy(true);
@@ -446,12 +483,16 @@ function ManagedSessionsContent({
const admission = attempt.sessionId
? await provider.submitPrompt(
attempt.sessionId,
{ text: attempt.text },
{
text: attempt.text,
...(attempt.input ? { input: attempt.input } : {}),
},
opts,
)
: await provider.createSession(
{
text: attempt.text,
...(attempt.input ? { input: attempt.input } : {}),
workspaceCwd: provider.acceptsWorkspaceCwd
? attempt.cwd
: undefined,
@@ -492,6 +533,8 @@ function ManagedSessionsContent({
setPending(undefined);
persistPending(pendingKey, undefined);
if (selection.current === selectedAtSubmit) setText(attempt.text);
if (selection.current === selectedAtSubmit)
restoreManagedAttachments(composerRef.current, attempt.input);
}
if (selection.current === selectedAtSubmit)
setError(failure instanceof Error ? failure.message : String(failure));
@@ -968,6 +1011,10 @@ function ManagedSessionsContent({
if (pending.sessionId === sessionId) {
setText(pending.text);
composerRef.current?.setText(pending.text);
restoreManagedAttachments(
composerRef.current,
pending.input,
);
}
composerCommit.current = undefined;
setError(undefined);
@@ -992,6 +1039,12 @@ function ManagedSessionsContent({
>
<ToolApproval
request={pendingApproval}
planExecutionMode={
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
@@ -1106,16 +1159,25 @@ function ManagedSessionsContent({
}
composerInputVersion={0}
onInputTextChange={setText}
onSubmit={(value, _images, _files, commit) => {
void submit(value, commit);
onSubmit={(value, images, files, commit) => {
void submit(value, commit, images, files);
return false;
}}
commands={[]}
workspaceFeaturesEnabled={false}
attachmentsEnabled={false}
attachmentsEnabled={
summary?.capabilities.inputAttachments === true
}
visibleToolbarActions={
interactive
? [
...(summary?.capabilities.inputAttachments ||
summary?.planModeSupported
? ['addMenu' as const]
: []),
...(summary?.planModeSupported || summary?.planMode
? ['plan' as const]
: []),
...(summary?.approvalMode
? ['approvalMode' as const]
: []),
@@ -1158,6 +1220,7 @@ function ManagedSessionsContent({
if (
!interactive ||
busy ||
encoding.current ||
pendingRef.current ||
active ||
pendingApproval ||
@@ -1176,6 +1239,26 @@ function ManagedSessionsContent({
contextUsageKnown={contextUsageKnown}
contextUsageAlwaysVisible={Boolean(sessionId)}
currentMode={summary?.approvalMode ?? ''}
planMode={summary?.planMode === true}
onTogglePlan={
summary?.planModeSupported && summary.approvalMode
? () => {
if (
!busy &&
!encoding.current &&
!active &&
!pendingApproval &&
!pendingRef.current &&
!cwd.isBlocked() &&
!modelChange.isBlocked()
)
void approvalChange.submit(
summary.approvalMode!,
!summary.planMode,
);
}
: undefined
}
availableModes={
approvalChange.options?.availableApprovalModes ??
(summary?.approvalMode ? [summary.approvalMode] : [])
@@ -1203,6 +1286,7 @@ function ManagedSessionsContent({
if (
!interactive ||
busy ||
encoding.current ||
pendingRef.current ||
active ||
pendingApproval ||
@@ -707,6 +707,120 @@ describe('WorkspaceBindingCreator', () => {
expect(commit).toHaveBeenCalledOnce();
});
it('freezes attachment-only input and Plan before asynchronous file reading', async () => {
const { createSession, getSessionOptions } = approvalSelection();
getSessionOptions.mockResolvedValue({
approvalMode: 'default',
availableApprovalModes: ['default', 'auto-edit'],
planModeSupported: true,
inputAttachments: true,
});
let finishReading!: (value: ArrayBuffer) => void;
const arrayBuffer = vi.fn(
() =>
new Promise<ArrayBuffer>((resolve) => {
finishReading = resolve;
}),
);
await render();
expect(editorMocks.props?.attachmentsEnabled).toBe(true);
expect(editorMocks.props?.visibleToolbarActions).toContain('plan');
await act(async () => editorMocks.props!.onTogglePlan!());
const commit = vi.fn();
await act(async () => {
editorMocks.props!.onSubmit(
'',
undefined,
[
{
name: 'notes.md',
media_type: 'text/markdown',
data: { size: 12, arrayBuffer } as Blob,
},
],
commit,
);
editorMocks.props!.onSelectModel!('agent-b');
editorMocks.props!.onSelectMode!('auto-edit');
editorMocks.props!.onTogglePlan!();
await flush();
});
expect(createSession).not.toHaveBeenCalled();
expect(editorMocks.props?.currentModel).toBe('agent-a');
expect(editorMocks.props?.planMode).toBe(true);
await act(async () => {
finishReading(new TextEncoder().encode('你好\nnotes').buffer);
await flush();
});
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-a',
approvalMode: 'default',
planMode: true,
text: '',
input: [
{
type: 'input_file',
name: 'notes.md',
mime_type: 'text/markdown',
text: '你好\nnotes',
},
],
}),
expect.any(Object),
);
expect(arrayBuffer).toHaveBeenCalledOnce();
expect(commit).toHaveBeenCalledOnce();
});
it('reuses every attachment byte and the request key after an unknown creation and remount', async () => {
const { createSession, getSessionOptions } = approvalSelection();
getSessionOptions.mockResolvedValue({
approvalMode: 'default',
availableApprovalModes: ['default'],
inputAttachments: true,
});
createSession.mockRejectedValueOnce(new Error('Delivery unknown'));
await render();
await act(async () => {
editorMocks.props!.onSubmit(
'Review',
undefined,
[{ name: 'notes.txt', media_type: 'text/plain', text: '原始内容' }],
vi.fn(),
);
await flush();
});
const [request, options] = createSession.mock.calls[0];
await act(async () => root.unmount());
root = createRoot(container);
await render();
await act(async () => {
editorMocks.props!.onSubmit(
'Changed draft',
undefined,
[{ name: 'other.txt', media_type: 'text/plain', text: 'different' }],
vi.fn(),
);
await flush();
});
expect(createSession).toHaveBeenLastCalledWith(
request,
expect.objectContaining({ idempotencyKey: options.idempotencyKey }),
);
expect(createSession).toHaveBeenCalledTimes(2);
expect(request.input).toEqual([
{ type: 'input_text', text: 'Review' },
{
type: 'input_file',
name: 'notes.txt',
mime_type: 'text/plain',
text: '原始内容',
},
]);
expect(onCreated).toHaveBeenCalledWith('session-a');
});
it('blocks creation until supported policy options are loaded and ignores stale Agent replies', async () => {
const { createSession, getSessionOptions } = approvalSelection();
let resolveA!: (options: ManagedSessionOptions) => void;
@@ -41,7 +41,15 @@ import type {
ManagedAgentWorkspace,
ManagedApprovalMode,
ManagedSessionOptions,
ManagedInputBlock,
} from './managed-agent-provider';
import type { PromptImage, PromptFile } from '../../adapters/promptTypes';
import {
encodeManagedInput,
isManagedInput,
managedInputText,
restoreManagedAttachments,
} from './managed-input';
import { isManagedApprovalMode } from './managed-agent-provider';
import { managedRequestId } from './managed-session-storage';
import { RemoteWorkspaceRegistration } from './RemoteWorkspaceRegistration';
@@ -50,11 +58,12 @@ interface PendingCreation {
agentId: string;
workspaceId: string;
cwdRelative: string;
input: [] | [{ type: 'input_text'; text: string }];
input: ManagedInputBlock[];
clientId: string;
idempotencyKey: string;
sessionId?: string;
approvalMode?: ManagedApprovalMode;
planMode?: boolean;
}
interface PendingWorkspace {
@@ -107,7 +116,6 @@ function readPending(key: string): PendingCreation | undefined {
if (typeof value !== 'object' || !value) return undefined;
const item = value as Record<string, unknown>;
const input = item['input'];
const first: unknown = Array.isArray(input) ? input[0] : undefined;
if (
typeof item['agentId'] !== 'string' ||
typeof item['workspaceId'] !== 'string' ||
@@ -116,13 +124,9 @@ function readPending(key: string): PendingCreation | undefined {
typeof item['idempotencyKey'] !== 'string' ||
(item['approvalMode'] !== undefined &&
!isManagedApprovalMode(item['approvalMode'])) ||
!Array.isArray(input) ||
input.length > 1 ||
(input.length === 1 &&
(typeof first !== 'object' ||
first === null ||
(first as Record<string, unknown>)['type'] !== 'input_text' ||
typeof (first as Record<string, unknown>)['text'] !== 'string')) ||
(item['planMode'] !== undefined &&
typeof item['planMode'] !== 'boolean') ||
!isManagedInput(input) ||
(item['sessionId'] !== undefined && typeof item['sessionId'] !== 'string')
)
return undefined;
@@ -193,6 +197,8 @@ export function WorkspaceBindingCreator({
const [optionsLoading, setOptionsLoading] = useState(false);
const [optionsError, setOptionsError] = useState<string>();
const [optionsRevision, setOptionsRevision] = useState(0);
const [planMode, setPlanMode] = useState(false);
const encoding = useRef(false);
const [items, setItems] = useState<ManagedAgentWorkspace[]>([]);
const selectionKey = `qwen-managed-workspace-selection:${provider.storageKey}`;
const [selected, setSelected] = useState(() => {
@@ -445,9 +451,20 @@ export function WorkspaceBindingCreator({
}
}
async function createOrRead(text?: string, commit?: ComposerSubmitCommit) {
async function createOrRead(
text?: string,
commit?: ComposerSubmitCommit,
images?: PromptImage[],
files?: PromptFile[],
) {
const abort = commandLifetime.current;
if (!abort || abort.signal.aborted || busy || workspaceCreatingRef.current)
if (
!abort ||
abort.signal.aborted ||
busy ||
encoding.current ||
workspaceCreatingRef.current
)
return;
let attempt = pendingRef.current;
if (
@@ -458,7 +475,13 @@ export function WorkspaceBindingCreator({
return;
const firstSubmission = !attempt;
if (!attempt) {
if (text !== undefined && !text.trim()) return;
if (
text !== undefined &&
!text.trim() &&
!images?.length &&
!files?.length
)
return;
if (!approvalReady) return;
if (
!supported ||
@@ -470,16 +493,33 @@ export function WorkspaceBindingCreator({
if (text !== undefined) setError(t('managed.workspaceChoose'));
return;
}
let input: ManagedInputBlock[] = [];
encoding.current = true;
setBusy(true);
try {
if (text !== undefined)
input = await encodeManagedInput(text, images, files);
} catch (failure) {
setError(message(failure));
return;
} finally {
encoding.current = false;
setBusy(false);
}
if (abort.signal.aborted) return;
attempt = {
agentId,
workspaceId: selected,
cwdRelative,
input: text === undefined ? [] : [{ type: 'input_text', text }],
input,
clientId,
idempotencyKey: managedRequestId(),
...(supportsApprovalModeSelection && currentOptions
? { approvalMode: currentOptions.approvalMode }
: {}),
...(planMode && currentOptions?.planModeSupported
? { planMode: true }
: {}),
};
if (!savePending(pendingKey, attempt)) {
setError(t('managed.workspaceStorageUnavailable'));
@@ -501,20 +541,32 @@ export function WorkspaceBindingCreator({
...(attempt.approvalMode === undefined
? {}
: { approvalMode: attempt.approvalMode }),
...(attempt.planMode === undefined
? {}
: { planMode: attempt.planMode }),
};
const command = {
clientId: attempt.clientId,
idempotencyKey: attempt.idempotencyKey,
signal: abort.signal,
};
const initial = attempt.input[0];
const initial = attempt.input.length > 0;
if (initial && !binding.createSession)
throw new Error(
'The provider no longer supports initial Workspace input',
);
const admitted = initial
? await binding.createSession!(
{ ...request, text: initial.text },
{
...request,
text: managedInputText(attempt.input),
...(attempt.input.some(
(block) =>
block.type === 'input_file' || block.type === 'input_image',
)
? { input: attempt.input }
: {}),
},
command,
)
: await binding.createEmpty(request, command);
@@ -821,10 +873,13 @@ export function WorkspaceBindingCreator({
variant="outline"
disabled={busy}
onClick={() => {
const initial = pending.input[0];
const initial = pending.input;
updatePending(undefined);
acceptedCommit.current = undefined;
if (initial) editor.current?.setText(initial.text);
if (initial.length) {
editor.current?.setText(managedInputText(initial));
restoreManagedAttachments(editor.current, initial);
}
onAbandoned?.();
}}
>
@@ -1040,12 +1095,20 @@ export function WorkspaceBindingCreator({
registrationAvailable && Boolean(binding.registration)
}
onOpenExistingWorkspace={() => {
if (!busy && !pendingRef.current) setRegistrationOpen(true);
if (!busy && !encoding.current && !pendingRef.current)
setRegistrationOpen(true);
}}
attachmentsEnabled={false}
attachmentsEnabled={currentOptions?.inputAttachments === true}
visibleToolbarActions={
interactive
? [
...(currentOptions?.inputAttachments ||
currentOptions?.planModeSupported
? ['addMenu' as const]
: []),
...(currentOptions?.planModeSupported
? ['plan' as const]
: []),
...(displayedApprovalMode
? ['approvalMode' as const]
: []),
@@ -1061,6 +1124,24 @@ export function WorkspaceBindingCreator({
busy || workspaceCreating || Boolean(pending)
}
currentMode={displayedApprovalMode ?? ''}
planMode={
pending?.planMode ??
(currentOptions?.planModeSupported === true && planMode)
}
onTogglePlan={
currentOptions?.planModeSupported
? () => {
if (
!busy &&
!encoding.current &&
!pendingRef.current &&
!workspaceCreating &&
!optionsLoading
)
setPlanMode((value) => !value);
}
: undefined
}
availableModes={
pending?.approvalMode
? [pending.approvalMode]
@@ -1072,11 +1153,13 @@ export function WorkspaceBindingCreator({
Boolean(pending) ||
optionsLoading ||
!currentOptions ||
currentOptions.availableApprovalModes.length <= 1
(currentOptions.availableApprovalModes.length <= 1 &&
!currentOptions.planModeSupported)
}
onSelectMode={(value) => {
if (
!busy &&
!encoding.current &&
!workspaceCreating &&
!pendingRef.current &&
!optionsLoading &&
@@ -1091,7 +1174,8 @@ export function WorkspaceBindingCreator({
label: item.label,
}))}
onSelectModel={(id) => {
if (!busy && !pendingRef.current) setAgentId(id);
if (!busy && !encoding.current && !pendingRef.current)
setAgentId(id);
}}
workspaces={items.map((item) => ({
id: item.workspaceId,
@@ -1105,7 +1189,9 @@ export function WorkspaceBindingCreator({
scratchWorkspaceSupported={
interactive && workspaceCreationSupported
}
onCreateScratchWorkspace={() => setWorkspaceDialogOpen(true)}
onCreateScratchWorkspace={() => {
if (!encoding.current) setWorkspaceDialogOpen(true);
}}
workspaceMutationBusy={
busy || workspaceCreating || Boolean(pending)
}
@@ -1118,10 +1204,13 @@ export function WorkspaceBindingCreator({
Boolean(pending)
}
onSelectWorkspace={(id) => {
if (id && !busy && !pendingRef.current) choose(id);
if (id && !busy && !encoding.current && !pendingRef.current)
choose(id);
}}
composerInput={
pending?.input[0] ? { text: pending.input[0].text } : undefined
pending?.input.length
? { text: managedInputText(pending.input) }
: undefined
}
composerInputVersion={0}
disabled={
@@ -1139,8 +1228,8 @@ export function WorkspaceBindingCreator({
}
isPreparing={busy || loading || optionsLoading}
placeholderText={t('managed.prompt')}
onSubmit={(text, _images, _files, commit) => {
void createOrRead(text, commit);
onSubmit={(text, images, files, commit) => {
void createOrRead(text, commit, images, files);
return false;
}}
/>
@@ -605,11 +605,7 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
InputBlock: {
/** @enum {string} */
type: "input_text";
text: string;
};
InputBlock: components["schemas"]["InputTextBlock"] | components["schemas"]["InputFileBlock"] | components["schemas"]["InputImageBlock"];
PublicArtifact: {
/** @constant */
object: "agent.artifact";
@@ -665,15 +661,17 @@ export interface components {
environmentId?: string | null;
/** @description The server enforces the bound in UTF-16 code units (Java String.length()), so an astral character counts as two: a title this schema's code-point maxLength accepts can still exceed the server's 256-unit bound and answer 400 invalid_title. */
title?: string | null;
/** @description Each block adds to a 4,000,000-character aggregate admission budget per command (per-block 1,000,000-character cap applies). Admission ceiling only: a command whose serialized prompt or durable Session-store record exceeds the Hosted Harness's 64 KiB durable-record limit is accepted and then fails the Turn with hosted_harness_rejected. */
/** @description Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit and prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 48 KiB and requires input-attachments/1. */
input?: components["schemas"]["InputBlock"][];
metadata?: components["schemas"]["WebShellMetadata"];
workspace?: components["schemas"]["WebShellWorkspaceSelection"];
/**
* @description Optional policy for Workspace-bound creation only. Omission preserves the existing default and idempotency digest. Explicit selection is validated against the resolved Agent and tool profile and frozen in the Session; Shell refuses yolo and definitions retain their compiled policy.
* @description Optional initial execution policy for Workspace creation, validated against the resolved Agent and profile. Omission preserves its default and idempotency digest. Independent of planMode.
* @enum {string}
*/
approvalMode?: "default" | "auto-edit" | "yolo";
/** @description Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state. */
planMode?: boolean;
};
WebShellSubmitRequest: {
/** @description Trace correlation only. */
@@ -681,7 +679,7 @@ export interface components {
idempotencyKey: string;
/** Format: uuid */
sessionId: string;
/** @description Each block adds to a 4,000,000-character aggregate admission budget per command (per-block 1,000,000-character cap applies). Admission ceiling only: a command whose serialized prompt or durable Session-store record exceeds the Hosted Harness's 64 KiB durable-record limit is accepted and then fails the Turn with hosted_harness_rejected. */
/** @description Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit and prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 48 KiB and requires input-attachments/1. */
input?: components["schemas"]["InputBlock"][];
metadata?: components["schemas"]["WebShellMetadata"];
/**
@@ -765,6 +763,10 @@ export interface components {
* @enum {string}
*/
initialApprovalMode?: "default" | "auto-edit" | "yolo";
/** @description Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state. */
planMode?: boolean;
/** @description Optional deployment capability; missing means false. */
planModeSupported?: boolean;
};
WebShellSessionCapabilities: {
/**
@@ -812,6 +814,8 @@ export interface components {
modelChange?: boolean;
/** @description The caller owns this eligible Workspace Session with current OPERATOR or OWNER access. Built-in qwen-code requires approval-selection/1; stored AgentDefinitions on exactly hosted-workspace-files/1 or hosted-workspace-shell/1,/2 also require agent-definition-approval-selection/1. Idle-state admission is checked separately. Missing means false. */
approvalModeChange?: boolean;
/** @description Optional deployment capability; missing means false. */
inputAttachments?: boolean;
};
WebShellSessionPage: {
data: components["schemas"]["WebShellSession"][];
@@ -1336,7 +1340,7 @@ export interface components {
};
/** @description Optional preview for requested native read_file, write_file, edit or run_shell_command approvals. Missing or invalid input, and internal MCP inputs, omit the preview. No general secret redaction is performed; visibility follows Session read permissions. Deploy the version 2 Java reader before a Harness producer starts writing version 2 options. */
WebShellActionInputPreview: {
/** @description Exact prefix of the stored payloadJson ({toolName,input}), at most 8192 UTF-8 bytes ending at a complete code point. Truncated text may not be valid JSON. */
/** @description Exact UTF-8 prefix of payloadJson ({toolName,input}). Ordinary tools retain the 8192-byte preview. exit_plan_mode includes its complete input within the 64 KiB resource ceiling so a plan can be reviewed after refresh. Truncated ordinary previews may not be valid JSON. */
text: string;
/** @description True when text is shorter than the complete UTF-8 input. */
truncated: boolean;
@@ -1359,8 +1363,12 @@ export interface components {
WebShellSessionOptions: {
/** @enum {string} */
approvalMode: "default" | "auto-edit" | "yolo";
/** @description Effective modes for this Agent/profile. Stored definitions offer only their pinned mode; auto and plan are unsupported. */
/** @description Execution policies available at creation. Stored definitions retain their initial policy; existing eligible Sessions may select a policy separately. Plan is an independent overlay. */
availableApprovalModes: ("default" | "auto-edit" | "yolo")[];
/** @description Optional deployment capability; missing means false. */
planModeSupported?: boolean;
/** @description Optional deployment capability; missing means false. */
inputAttachments?: boolean;
};
/** @description Safe allowlisted observation for a Workspace-bound Session. Unknown counts are omitted, never estimated. */
WebShellModelContext: {
@@ -1413,6 +1421,10 @@ export interface components {
approvalRevision: number;
availableApprovalModes: ("default" | "auto-edit" | "yolo")[];
supported: boolean;
/** @description Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state. */
planMode?: boolean;
/** @description Optional deployment capability; missing means false. */
planModeSupported?: boolean;
};
WebShellChangeApprovalRequest: {
sessionId: string;
@@ -1422,6 +1434,8 @@ export interface components {
requestId?: string;
/** @enum {string} */
approvalMode: "default" | "auto-edit" | "yolo";
/** @description Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state. */
planMode?: boolean;
};
WebShellApprovalChangeResult: {
sessionId: string;
@@ -1430,6 +1444,8 @@ export interface components {
/** Format: int64 */
approvalRevision: number;
replayed: boolean;
/** @description Persisted public Plan overlay, independent of execution permission. Omission on a change preserves the selected state. */
planMode?: boolean;
};
WebShellRuntimeNode: {
nodeId: string;
@@ -1489,6 +1505,27 @@ export interface components {
workspaceId: string;
cwdRelative: string;
};
InputTextBlock: {
/** @enum {string} */
type: "input_text" | "text";
text: string;
};
/** @description Inline UTF-8 text file. No host paths, URLs, binary or NUL. Conservative text MIME/extension validation; the complete canonical input containing attachments is limited to 48 KiB. */
InputFileBlock: {
/** @enum {string} */
type: "input_file";
name: string;
mime_type: string;
text: string;
};
/** @description Canonical base64 image with a matching signature. The complete canonical input containing attachments is limited to 48 KiB. */
InputImageBlock: {
/** @enum {string} */
type: "input_image";
/** @enum {string} */
mime_type: "image/png" | "image/jpeg" | "image/webp";
data: string;
};
};
responses: {
/** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */
@@ -13,6 +13,59 @@ import type {
} from './java-managed-agent-client';
describe('java managed agent event projector', () => {
it('rebuilds attachment-only history from materialized and snapshot attributes without losing bytes', () => {
const prompt = [
{
type: 'resource',
resource: {
uri: 'managed-input:///notes%201.md',
mimeType: 'text/markdown',
text: '你好\ncontents',
},
},
{ type: 'image', mimeType: 'image/png', data: 'aW1hZ2U=' },
];
const item: JavaAgentItem = {
itemId: 'user-file',
sessionId: 's',
turnId: 't',
type: 'message',
role: 'user',
status: 'completed',
content: [],
attributes: { inputAttachments: prompt },
firstSequence: 2,
lastSequence: 2,
createdAt: 1,
updatedAt: 1,
};
const snapshot = managedEventsToMessages(projectJavaAgentItem(item), '');
const live = projectJavaAgentEvent({
schemaVersion: 1,
projectionVersion: 1,
sequence: 2,
eventId: 'accepted',
sessionId: 's',
turnId: 't',
type: 'turn.accepted',
createdAt: 1,
terminal: false,
data: { input: prompt },
});
expect(snapshot).toEqual(managedEventsToMessages([live!], ''));
expect(snapshot[0]).toMatchObject({
role: 'user',
content: '',
files: [
{
name: 'notes 1.md',
mimeType: 'text/markdown',
text: '你好\ncontents',
},
],
images: [{ data: 'aW1hZ2U=', mimeType: 'image/png' }],
});
});
it.each(['session.context.changed', 'session.approval.changed'])(
'projects %s without creating or settling a chat message',
(type) => {
@@ -33,9 +33,11 @@ export function projectJavaAgentItem(
return [
projectedItemEvent(item, item.firstSequence, 'accepted', {
itemId: item.itemId,
prompt: item.content
.filter((part) => part.type === 'input_text')
.map((part) => ({ type: 'text', text: part.text })),
prompt: Array.isArray(item.attributes?.['inputAttachments'])
? item.attributes['inputAttachments']
: item.content
.filter((part) => part.type === 'input_text')
.map((part) => ({ type: 'text', text: part.text })),
}),
];
}
@@ -106,7 +106,9 @@ export function createJavaManagedAgentProvider(
workspaceId: string;
cwdRelative: string;
text?: string;
input?: import('./managed-agent-provider').ManagedInputBlock[];
approvalMode?: ManagedApprovalMode;
planMode?: boolean;
},
command: ManagedAgentCommandOptions,
) {
@@ -118,10 +120,14 @@ export function createJavaManagedAgentProvider(
...(request.approvalMode === undefined
? {}
: { approvalMode: request.approvalMode }),
...(request.planMode === undefined
? {}
: { planMode: request.planMode }),
input:
request.text === undefined
request.input ??
(request.text === undefined
? []
: [{ type: 'input_text', text: request.text }],
: [{ type: 'input_text', text: request.text }]),
metadata: { clientId: command.clientId },
workspace: {
workspaceId: request.workspaceId,
@@ -219,6 +225,12 @@ export function createJavaManagedAgentProvider(
approvalRevision: result.approvalRevision,
availableApprovalModes: result.availableApprovalModes,
supported: result.supported,
...(typeof result.planMode === 'boolean'
? { planMode: result.planMode }
: {}),
...(typeof result.planModeSupported === 'boolean'
? { planModeSupported: result.planModeSupported }
: {}),
};
},
async submit(sessionId, request, command) {
@@ -244,6 +256,9 @@ export function createJavaManagedAgentProvider(
approvalMode: result.approvalMode,
approvalRevision: result.approvalRevision,
replayed: result.replayed,
...(typeof result.planMode === 'boolean'
? { planMode: result.planMode }
: {}),
};
},
},
@@ -482,11 +497,11 @@ export function createJavaManagedAgentProvider(
getSessionOptions: (agentId, request) =>
client.getSessionOptions(agentId, request.signal),
createEmpty: (
{ agentId, workspaceId, cwdRelative, approvalMode },
{ agentId, workspaceId, cwdRelative, approvalMode, planMode },
command,
) =>
createWorkspaceSession(
{ agentId, workspaceId, cwdRelative, approvalMode },
{ agentId, workspaceId, cwdRelative, approvalMode, planMode },
command,
),
createSession: createWorkspaceSession,
@@ -539,7 +554,7 @@ export function createJavaManagedAgentProvider(
// environmentId scopes the storageKey only: the standalone Java
// server 400s any non-blank value with unsupported_feature.
title: titleFor(request.text),
input: [{ type: 'input_text', text: request.text }],
input: request.input ?? [{ type: 'input_text', text: request.text }],
metadata: { clientId: command.clientId },
},
command.signal,
@@ -555,7 +570,7 @@ export function createJavaManagedAgentProvider(
requestId: managedRequestId(),
idempotencyKey: command.idempotencyKey,
sessionId,
input: [{ type: 'input_text', text: request.text }],
input: request.input ?? [{ type: 'input_text', text: request.text }],
metadata: { clientId: command.clientId },
},
command.signal,
@@ -625,6 +640,12 @@ function toSessionSummary(
return {
sessionId: session.sessionId,
agentId: session.agentId,
...(typeof session.planMode === 'boolean'
? { planMode: session.planMode }
: {}),
...(typeof session.planModeSupported === 'boolean'
? { planModeSupported: session.planModeSupported }
: {}),
...(isManagedApprovalMode(session.approvalMode)
? { approvalMode: session.approvalMode }
: {}),
@@ -669,6 +690,9 @@ function toSessionSummary(
? { approvalModeChange: true }
: {}),
...(session.capabilities?.actions === true ? { actions: true } : {}),
...(session.capabilities?.inputAttachments === true
? { inputAttachments: true }
: {}),
},
...(errorCode ? { failure: { code: errorCode, message: errorCode } } : {}),
};
@@ -20,6 +20,7 @@ export type ManagedAgentRuntimeState =
| 'failed';
export type ManagedApprovalMode = 'default' | 'auto-edit' | 'yolo';
export type ManagedInputBlock = components['schemas']['InputBlock'];
export function isManagedApprovalMode(
value: unknown,
@@ -30,11 +31,14 @@ export function isManagedApprovalMode(
export interface ManagedSessionOptions {
approvalMode: ManagedApprovalMode;
availableApprovalModes: ManagedApprovalMode[];
planModeSupported?: boolean;
inputAttachments?: boolean;
}
export interface ManagedSessionApproval extends ManagedSessionOptions {
approvalRevision: number;
supported: boolean;
planMode?: boolean;
}
export interface ManagedApprovalChangeResult {
@@ -42,6 +46,7 @@ export interface ManagedApprovalChangeResult {
approvalMode: ManagedApprovalMode;
approvalRevision: number;
replayed: boolean;
planMode?: boolean;
}
export interface ManagedModelContext {
@@ -74,6 +79,8 @@ export interface ManagedAgentSessionSummary {
approvalMode?: ManagedApprovalMode;
initialApprovalMode?: ManagedApprovalMode;
approvalRevision?: number;
planMode?: boolean;
planModeSupported?: boolean;
modelContext?: ManagedModelContext;
modelSelection?: ManagedModelSelection;
activeTurnId?: string;
@@ -102,6 +109,7 @@ export interface ManagedAgentSessionSummary {
cwdChange?: boolean;
modelChange?: boolean;
approvalModeChange?: boolean;
inputAttachments?: boolean;
};
failure?: { code: string; message: string };
}
@@ -281,7 +289,11 @@ export interface ManagedAgentProvider {
): Promise<ManagedSessionApproval>;
submit(
sessionId: string,
request: { approvalMode: ManagedApprovalMode; expectedRevision: number },
request: {
approvalMode: ManagedApprovalMode;
expectedRevision: number;
planMode?: boolean;
},
options: ManagedAgentCommandOptions & { requestId: string },
): Promise<ManagedApprovalChangeResult>;
};
@@ -338,6 +350,7 @@ export interface ManagedAgentProvider {
workspaceId: string;
cwdRelative: string;
approvalMode?: ManagedApprovalMode;
planMode?: boolean;
},
options: ManagedAgentCommandOptions,
): Promise<{ sessionId: string }>;
@@ -347,7 +360,9 @@ export interface ManagedAgentProvider {
workspaceId: string;
cwdRelative: string;
text: string;
input?: ManagedInputBlock[];
approvalMode?: ManagedApprovalMode;
planMode?: boolean;
},
options: ManagedAgentCommandOptions,
): Promise<{ sessionId: string }>;
@@ -374,12 +389,16 @@ export interface ManagedAgentProvider {
},
): Promise<ManagedAgentSessionTranscript>;
createSession(
request: { text: string; workspaceCwd?: string },
request: {
text: string;
workspaceCwd?: string;
input?: ManagedInputBlock[];
},
options: ManagedAgentCommandOptions,
): Promise<ManagedAgentTurnAdmission>;
submitPrompt(
sessionId: string,
request: { text: string },
request: { text: string; input?: ManagedInputBlock[] },
options: ManagedAgentCommandOptions,
): Promise<ManagedAgentTurnAdmission>;
cancel(
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Message } from '../../adapters/types';
import { isExitPlanApprovalRequest } from '../../utils/todos';
import type { ManagedAgentPendingAction } from './managed-agent-provider';
import { managedEventsToMessages } from './managed-session-messages';
import {
@@ -39,6 +40,65 @@ function toolGroup(turnId: string, callId: string): Message {
}
describe('Managed approval presentation', () => {
it.each(['preview', 'transcript'] as const)(
'identifies a public plan approval from its %s for the shared plan card',
(source) => {
const plan = '# Plan\n1. Read files\n2. Make changes after approval';
const text = JSON.stringify({
toolName: 'exit_plan_mode',
input: { plan },
});
const messages: Message[] =
source === 'transcript'
? [
{
id: 'managed:s1:turn-2:1',
role: 'tool_group',
tools: [
{
callId: 'turn-2:call-1',
toolName: 'exit_plan_mode',
status: 'pending',
args: { plan },
},
],
} as Message,
]
: [];
const request = toManagedPermissionRequest(
{
...action,
toolName: 'exit_plan_mode',
inputPreview: { text, truncated: false, byteLength: text.length },
},
messages,
);
expect(request.rawInput).toEqual({ plan });
expect(request.content).toEqual([{ type: 'text', text: plan }]);
expect(isExitPlanApprovalRequest(request)).toBe(true);
expect(request.toolKind).toBe('switch_mode');
},
);
it('recovers a complete public plan from its durable preview after transcript refresh', () => {
const plan = '# Plan\n1. Read files\n2. Make changes after approval';
const text = JSON.stringify({
toolName: 'exit_plan_mode',
input: { plan },
});
const request = toManagedPermissionRequest(
{
...action,
toolName: 'exit_plan_mode',
inputPreview: { text, truncated: false, byteLength: text.length },
},
[],
);
expect(request.rawInput).toEqual({ plan });
expect(request.options[0]?.id).toBe('allow');
expect(request.options[1]?.kind).toBe('reject_once');
});
it('uses the exact Action preview without parsing a truncated JSON prefix', () => {
const text = ' {"toolName":"write_file","input":{"content":"<b>😀';
const request = toManagedPermissionRequest(
@@ -51,6 +51,29 @@ export function toManagedPermissionRequest(
messages: readonly Message[],
): PermissionRequest {
const tool = findManagedApprovalTool(messages, action);
let rawInput = tool?.args;
if (
!rawInput &&
action.toolName === 'exit_plan_mode' &&
action.inputPreview &&
!action.inputPreview.truncated
) {
try {
const preview: unknown = JSON.parse(action.inputPreview.text);
if (preview && typeof preview === 'object' && !Array.isArray(preview)) {
const input = (preview as Record<string, unknown>)['input'];
if (
input &&
typeof input === 'object' &&
!Array.isArray(input) &&
typeof (input as Record<string, unknown>)['plan'] === 'string'
)
rawInput = input as Record<string, unknown>;
}
} catch {
/* An incomplete preview remains explicitly marked as such. */
}
}
const toolCallId =
tool?.callId ??
(action.turnId ? `${action.turnId}:${action.functionCallId}` : undefined);
@@ -59,19 +82,27 @@ export function toManagedPermissionRequest(
sessionId: action.sessionId,
...(toolCallId ? { toolCallId } : {}),
toolName: action.toolName,
...(action.toolName === 'exit_plan_mode'
? { toolKind: 'switch_mode' as const }
: {}),
title: tool?.title ?? action.toolName,
content: tool?.args
content: rawInput
? [
{
type: 'text',
text: escapePreviewText(JSON.stringify(tool.args, null, 2)),
text: escapePreviewText(
action.toolName === 'exit_plan_mode' &&
typeof rawInput['plan'] === 'string'
? rawInput['plan']
: JSON.stringify(rawInput, null, 2),
),
},
]
: action.inputPreview
? [{ type: 'text', text: escapePreviewText(action.inputPreview.text) }]
: [],
...(tool?.args
? { rawInput: tool.args, contentIsInput: true }
...(rawInput
? { rawInput, contentIsInput: true }
: action.inputPreview
? { contentIsInput: true }
: {}),
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest';
import { encodeManagedInput, isManagedInput } from './managed-input';
describe('Managed composer input snapshots', () => {
it('freezes UTF-8 Blob contents with text and images before a request can be persisted', async () => {
const arrayBuffer = vi.fn(
async () => new TextEncoder().encode('你好\ncontents').buffer,
);
const png = btoa('\x89PNG\r\n\x1a\n');
const input = await encodeManagedInput(
'Review',
[{ data: png, media_type: 'image/png' }],
[
{
name: 'notes.md',
media_type: 'text/markdown',
data: { size: 16, arrayBuffer } as Blob,
},
],
);
expect(input).toEqual([
{ type: 'input_text', text: 'Review' },
{
type: 'input_file',
name: 'notes.md',
mime_type: 'text/markdown',
text: '你好\ncontents',
},
{ type: 'input_image', mime_type: 'image/png', data: png },
]);
const replay: unknown = JSON.parse(JSON.stringify(input));
expect(isManagedInput(replay)).toBe(true);
expect(replay).toEqual(input);
expect(arrayBuffer).toHaveBeenCalledOnce();
});
it('accepts a file-only draft and validates restored snapshots as strictly as fresh input', async () => {
expect(
await encodeManagedInput(
'',
[],
[{ name: 'script.ts', media_type: '', text: 'const x = 1;' }],
),
).toEqual([
{
type: 'input_file',
name: 'script.ts',
mime_type: 'application/octet-stream',
text: 'const x = 1;',
},
]);
expect(
isManagedInput([
{
type: 'input_file',
name: 'secret.pdf',
mime_type: 'application/pdf',
text: '%PDF',
},
]),
).toBe(false);
expect(
isManagedInput([
{ type: 'input_text', text: 'hello', data: 'discarded' },
]),
).toBe(false);
expect(
isManagedInput([
{ type: 'input_image', mime_type: 'image/png', data: 'not-base64' },
]),
).toBe(false);
});
it('refuses binary, malformed UTF-8, empty and oversized files without a partial request', async () => {
await expect(
encodeManagedInput(
'',
[],
[{ name: 'a.pdf', media_type: 'application/pdf', text: 'file' }],
),
).rejects.toThrow('UTF-8');
await expect(
encodeManagedInput(
'',
[],
[{ name: 'a.txt', media_type: 'text/plain', text: '' }],
),
).rejects.toThrow();
await expect(
encodeManagedInput(
'',
[],
[{ name: '../a.txt', media_type: 'text/plain', text: 'x' }],
),
).rejects.toThrow();
await expect(
encodeManagedInput(
'',
[],
[
{
name: 'a.txt',
media_type: 'text/plain',
data: {
size: 2,
arrayBuffer: async () => new Uint8Array([255, 255]).buffer,
} as Blob,
},
],
),
).rejects.toThrow('not valid UTF-8');
await expect(
encodeManagedInput(
'x'.repeat(49000),
[],
[{ name: 'a.txt', media_type: 'text/plain', text: 'x'.repeat(1000) }],
),
).rejects.toThrow('48 KiB');
});
});
@@ -0,0 +1,220 @@
import type { PromptFile, PromptImage } from '../../adapters/promptTypes';
import type { ManagedInputBlock } from './managed-agent-provider';
import type { EditorHandle } from '../../hooks/useComposerCore';
const MAX_INPUT_BYTES = 48 * 1024;
const TEXT_EXTENSIONS = new Set([
'txt',
'md',
'markdown',
'csv',
'log',
'json',
'jsonl',
'yaml',
'yml',
'xml',
'html',
'htm',
'css',
'scss',
'js',
'jsx',
'ts',
'tsx',
'py',
'java',
'sql',
'sh',
'bash',
'rs',
'go',
'c',
'h',
'cpp',
'toml',
'ini',
'properties',
'svg',
]);
const TEXT_MIMES = new Set([
'application/json',
'application/xml',
'application/javascript',
'application/x-yaml',
'application/yaml',
'image/svg+xml',
]);
function privateBlock(block: ManagedInputBlock) {
if (block.type === 'input_file')
return {
type: 'resource',
resource: {
uri: `managed-input:///${encodeURIComponent(block.name).replace(/[!'()~]/gu, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)}`,
mimeType: block.mime_type,
text: block.text,
},
};
if (block.type === 'input_image')
return { type: 'image', data: block.data, mimeType: block.mime_type };
return { type: 'text', text: block.text };
}
export function isManagedInput(value: unknown): value is ManagedInputBlock[] {
if (!Array.isArray(value) || value.length > 100) return false;
try {
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item))
return false;
const block = item as Record<string, unknown>;
if (block['type'] === 'input_text' || block['type'] === 'text') {
if (
Object.keys(block).length !== 2 ||
typeof block['text'] !== 'string' ||
!block['text']
)
return false;
} else if (block['type'] === 'input_file') {
const name = block['name'];
const mime = block['mime_type'];
if (
Object.keys(block).length !== 4 ||
typeof name !== 'string' ||
!name.trim() ||
name.length > 255 ||
name === '.' ||
name === '..' ||
/[/\\\p{Cc}]/u.test(name) ||
typeof mime !== 'string' ||
!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(mime) ||
!(
mime.startsWith('text/') ||
TEXT_MIMES.has(mime) ||
(mime === 'application/octet-stream' &&
TEXT_EXTENSIONS.has(name.split('.').at(-1)!.toLowerCase()))
) ||
typeof block['text'] !== 'string' ||
!block['text'] ||
block['text'].includes('\0')
)
return false;
} else if (block['type'] === 'input_image') {
if (
Object.keys(block).length !== 3 ||
typeof block['data'] !== 'string' ||
!block['data'] ||
block['data'].length > MAX_INPUT_BYTES
)
return false;
const bytes = atob(block['data']);
if (btoa(bytes) !== block['data']) return false;
const valid =
block['mime_type'] === 'image/png'
? bytes.startsWith('\x89PNG\r\n\x1a\n')
: block['mime_type'] === 'image/jpeg'
? bytes.startsWith('\xff\xd8\xff')
: block['mime_type'] === 'image/webp' &&
bytes.startsWith('RIFF') &&
bytes.slice(8, 12) === 'WEBP';
if (!valid) return false;
} else return false;
}
return (
!value.some(
(block) => block.type === 'input_file' || block.type === 'input_image',
) ||
new TextEncoder().encode(JSON.stringify(value.map(privateBlock)))
.length <= MAX_INPUT_BYTES
);
} catch {
return false;
}
}
export async function encodeManagedInput(
text: string,
images: readonly PromptImage[] = [],
files: readonly PromptFile[] = [],
): Promise<ManagedInputBlock[]> {
const blocks: ManagedInputBlock[] = text
? [{ type: 'input_text', text }]
: [];
for (const file of files) {
if (file.data && file.data.size > MAX_INPUT_BYTES)
throw new Error(
'Attachments and text together must fit within 48 KiB of encoded input.',
);
let contents = file.text;
if (contents === undefined && file.data) {
try {
contents = new TextDecoder('utf-8', { fatal: true }).decode(
await file.data.arrayBuffer(),
);
} catch {
throw new Error(
'This file is not valid UTF-8 text. PDF, Office, audio and video files are not supported.',
);
}
}
blocks.push({
type: 'input_file',
name: file.name,
mime_type:
file.media_type.split(';')[0]?.trim().toLowerCase() ||
'application/octet-stream',
text: contents ?? '',
});
}
for (const image of images) {
const mime = image.media_type;
if (mime !== 'image/png' && mime !== 'image/jpeg' && mime !== 'image/webp')
throw new Error('Only PNG, JPEG and WebP images are supported.');
blocks.push({
type: 'input_image',
data: image.data,
mime_type: mime,
});
}
if (!isManagedInput(blocks))
throw new Error(
'Use a nonempty UTF-8 text file or a valid PNG, JPEG or WebP image. Attachments and text together must fit within 48 KiB of encoded input.',
);
return blocks;
}
export function managedInputText(input: readonly ManagedInputBlock[]): string {
return input
.flatMap((block) =>
block.type === 'input_text' || block.type === 'text' ? [block.text] : [],
)
.join('\n');
}
export function restoreManagedAttachments(
editor: EditorHandle | null,
input?: readonly ManagedInputBlock[],
): void {
if (
!editor ||
!input?.some(
(block) => block.type === 'input_image' || block.type === 'input_file',
) ||
editor.hasAttachments()
)
return;
editor.restoreImages(
input.flatMap((block) =>
block.type === 'input_image'
? [{ data: block.data, media_type: block.mime_type }]
: [],
),
);
editor.restoreFiles(
input.flatMap((block) =>
block.type === 'input_file'
? [{ name: block.name, media_type: block.mime_type, text: block.text }]
: [],
),
);
}
@@ -107,6 +107,31 @@ export function managedEventsToMessages(
? [{ data: block['data'], mimeType: block['mimeType'] }]
: [];
});
const files = prompt.flatMap((value: unknown) => {
const block = record(value);
const resource = record(block['resource']);
if (
block['type'] !== 'resource' ||
typeof resource['uri'] !== 'string' ||
!resource['uri'].startsWith('managed-input:///') ||
typeof resource['text'] !== 'string' ||
typeof resource['mimeType'] !== 'string'
)
return [];
try {
return [
{
name: decodeURIComponent(
resource['uri'].slice('managed-input:///'.length),
),
text: resource['text'],
mimeType: resource['mimeType'],
},
];
} catch {
return [];
}
});
messages.push({
id,
role: 'user',
@@ -116,6 +141,7 @@ export function managedEventsToMessages(
.join('\n'),
timestamp: event.at,
...(images.length ? { images } : {}),
...(files.length ? { files } : {}),
});
settle();
} else if (
@@ -128,6 +128,59 @@ describe('useManagedApprovalChange', () => {
expect(sessionStorage.getItem(key)).toBeNull();
});
it('persists an independent Plan toggle with the same execution mode and confirms both fields', async () => {
vi.mocked(provider.approvalChange!.queryOptions).mockResolvedValue({
...options,
planModeSupported: true,
planMode: false,
});
vi.mocked(provider.approvalChange!.submit).mockResolvedValue({
...result,
approvalMode: 'default',
planMode: true,
});
refresh.mockResolvedValue({
...summary,
approvalRevision: 1,
planMode: true,
});
await render();
await act(async () => {
await current.submit('default', true);
});
expect(provider.approvalChange!.submit).toHaveBeenCalledWith(
's1',
{ approvalMode: 'default', expectedRevision: 0, planMode: true },
expect.anything(),
);
expect(current.blocked).toBe(false);
expect(sessionStorage.getItem(key)).toBeNull();
});
it('keeps a Plan receipt unconfirmed if readback reports only the mode and revision', async () => {
vi.mocked(provider.approvalChange!.queryOptions).mockResolvedValue({
...options,
planModeSupported: true,
});
vi.mocked(provider.approvalChange!.submit).mockResolvedValue({
...result,
approvalMode: 'default',
planMode: true,
});
refresh.mockResolvedValue({
...summary,
approvalRevision: 1,
planMode: false,
});
await render();
await act(async () => {
await current.submit('default', true);
});
expect(current.blocked).toBe(true);
expect(current.pending?.planMode).toBe(true);
expect(current.errorCode).toBe('unconfirmed');
});
it('restores a lost ACK after refresh and retries the exact frozen request only on confirmation', async () => {
vi.mocked(provider.approvalChange!.submit).mockRejectedValueOnce(
new TypeError('lost response'),
@@ -15,6 +15,7 @@ interface ApprovalIntent {
expectedRevision: number;
idempotencyKey: string;
requestId: string;
planMode?: boolean;
}
function readIntent(
@@ -33,7 +34,9 @@ function readIntent(
typeof value['requestId'] !== 'string' ||
!value['requestId'] ||
!Number.isSafeInteger(value['expectedRevision']) ||
Number(value['expectedRevision']) < 0
Number(value['expectedRevision']) < 0 ||
(value['planMode'] !== undefined &&
typeof value['planMode'] !== 'boolean')
)
return undefined;
return item as ApprovalIntent;
@@ -182,6 +185,9 @@ export function useManagedApprovalChange(
{
approvalMode: intent.approvalMode,
expectedRevision: intent.expectedRevision,
...(intent.planMode === undefined
? {}
: { planMode: intent.planMode }),
},
{
clientId,
@@ -195,6 +201,8 @@ export function useManagedApprovalChange(
if (
result.sessionId !== intent.sessionId ||
result.approvalMode !== intent.approvalMode ||
(intent.planMode !== undefined &&
result.planMode !== intent.planMode) ||
!Number.isSafeInteger(result.approvalRevision) ||
result.approvalRevision <= intent.expectedRevision
)
@@ -206,7 +214,9 @@ export function useManagedApprovalChange(
latest?.sessionId !== intent.sessionId ||
revision < result.approvalRevision ||
(revision === result.approvalRevision &&
latest.approvalMode !== intent.approvalMode)
(latest.approvalMode !== intent.approvalMode ||
(intent.planMode !== undefined &&
latest.planMode !== intent.planMode)))
)
throw new Error('Approval selection is not confirmed');
update();
@@ -239,7 +249,7 @@ export function useManagedApprovalChange(
}
}
async function submit(approvalMode: ManagedApprovalMode) {
async function submit(approvalMode: ManagedApprovalMode, planMode?: boolean) {
const current = summaryRef.current;
const options = optionsRef.current;
if (
@@ -253,12 +263,14 @@ export function useManagedApprovalChange(
current.capabilities.approvalModeChange !== true ||
!provider.approvalChange ||
!options?.supported ||
(planMode !== undefined && options.planModeSupported !== true) ||
!options.availableApprovalModes.includes(approvalMode) ||
!['created', 'completed', 'failed', 'cancelled'].includes(
current.phase,
) ||
options.approvalRevision !== (current.approvalRevision ?? 0) ||
approvalMode === current.approvalMode
(approvalMode === current.approvalMode &&
(planMode === undefined || planMode === Boolean(current.planMode)))
)
return;
const intent: ApprovalIntent = {
@@ -267,6 +279,7 @@ export function useManagedApprovalChange(
expectedRevision: options.approvalRevision,
idempotencyKey: managedRequestId(),
requestId: managedRequestId(),
...(planMode === undefined ? {} : { planMode }),
};
if (!persist(key, intent)) {
setState((value) => ({ ...value, errorCode: 'storage_unavailable' }));
@@ -62,7 +62,8 @@ describe('useManagedSession', () => {
modelSelection: { modelId: 'A', revision: 1 },
approvalMode: 'default',
initialApprovalMode: 'default',
approvalRevision: 0,
approvalRevision: 1,
planMode: true,
modelContext: {
modelId: 'A',
inputTokens: 200,
@@ -76,12 +77,14 @@ describe('useManagedSession', () => {
approvalMode: 'yolo',
initialApprovalMode: 'default',
approvalRevision: 2,
planMode: false,
})
.mockResolvedValue({
sessionId: 'session-1',
modelSelection: { modelId: 'A', revision: 1 },
approvalMode: 'default',
approvalRevision: 0,
approvalRevision: 1,
planMode: true,
modelContext: {
modelId: 'A',
inputTokens: 200,
@@ -115,6 +118,7 @@ describe('useManagedSession', () => {
approvalMode: 'yolo',
initialApprovalMode: 'default',
approvalRevision: 2,
planMode: false,
});
await act(async () => {
await latest!.refreshSummary();
@@ -128,6 +132,7 @@ describe('useManagedSession', () => {
approvalMode: 'yolo',
initialApprovalMode: 'default',
approvalRevision: 2,
planMode: false,
});
});
@@ -174,6 +174,7 @@ export function useManagedSession(
approvalMode: previous.approvalMode,
approvalRevision: previous.approvalRevision,
initialApprovalMode: previous.initialApprovalMode,
planMode: previous.planMode,
};
}
summaryRef.current = next;
@@ -1010,6 +1010,23 @@ describe('ToolApproval accessibility', () => {
act(() => optionButtons()[0].click());
expect(onConfirm).toHaveBeenCalledWith(planRequest.id, 'reject');
});
it('labels the Managed allow/deny plan options with the frozen execution policy', () => {
const req = {
...planRequest,
options: [
{ id: 'allow', label: 'Allow', kind: 'allow_once' as const },
{ id: 'deny', label: 'Deny', kind: 'reject_once' as const },
],
};
render(undefined, req, undefined, 'en', undefined, 'yolo');
expect(optionButtons().map((button) => button.dataset.optionId)).toEqual([
'deny',
'allow',
]);
expect(container!.textContent).toContain('Full Access');
act(() => optionButtons()[1].click());
expect(onConfirm).toHaveBeenCalledWith(req.id, 'allow');
});
it('keeps ordinary tool permissions unchanged when a plan execution mode is supplied', () => {
render(undefined, request, undefined, 'en', undefined, 'yolo');
@@ -277,6 +277,7 @@ export function ToolApproval({
? request.options.filter(
(option) =>
option.id === 'restore_previous' ||
option.id === 'allow' ||
option.kind === 'reject_once' ||
option.kind === 'reject_always',
)
@@ -307,7 +308,10 @@ export function ToolApproval({
if (option.kind === 'allow_once') return t('approval.goal.confirm');
if (option.kind === 'reject_once') return t('approval.goal.reject');
}
if (hasPlanExecutionMode && option.id === 'restore_previous') {
if (
hasPlanExecutionMode &&
(option.id === 'restore_previous' || option.id === 'allow')
) {
return t('approval.option.executePlan', {
mode: t(`mode.label.${planExecutionMode}`),
});