mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(managed-agent): add Shell Full Access and session approval switching (#14323)
* feat(web-shell): integrate Managed session workflow * docs(managed-agent): align model-switch acceptance translations * fix(managed-agent): reconcile WebShell integration checks * fix(managed-agent): address WebShell review feedback * fix(managed-agent): align schema projections and CI probes * feat(managed-agent): add Shell Full Access and approval switching Freeze approval selection per Turn, preserve legacy retry identity, and allow owner-authorized idle policy changes with durable WebShell confirmation. Co-authored-by: Qwen-Coder <[email protected]> * feat(web-shell): integrate Managed session workflow * docs(managed-agent): align model-switch acceptance translations * fix(managed-agent): reconcile WebShell integration checks * fix(managed-agent): address WebShell review feedback * fix(managed-agent): align schema projections and CI probes * feat(web-shell): integrate Managed session workflow * docs(managed-agent): align model-switch acceptance translations * fix(managed-agent): reconcile WebShell integration checks * fix(managed-agent): address WebShell review feedback * fix(managed-agent): align schema projections and CI probes * fix(web-shell): scope Local approval overlays to the active backend * test(web-shell): refresh transcript size evidence after main sync * feat(web-shell): integrate Managed session workflow * docs(managed-agent): align model-switch acceptance translations * fix(managed-agent): reconcile WebShell integration checks * fix(managed-agent): address WebShell review feedback * fix(managed-agent): align schema projections and CI probes * fix(web-shell): scope Local approval overlays to the active backend * test(web-shell): refresh transcript size evidence after main sync --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
a8eb74fdf5
commit
af9bd8ed05
59 files changed
+3032
-167
No files matched your search
@@ -2,6 +2,10 @@
|
||||
|
||||
[English](2026-10-15-managed-approval-selector.md) | [中文](2026-10-15-managed-approval-selector.zh-CN.md)
|
||||
|
||||
## Status
|
||||
|
||||
This document records the initial creation-selector slice. Its fixed-policy and Shell Full Access restrictions are superseded by [Managed approval selection](managed-approval-selection.md), which adds capability-gated Full Access and idle Session policy changes while preserving this creation contract.
|
||||
|
||||
## Problem and current behavior
|
||||
|
||||
Local WebShell exposes an approval selector, while Managed creation hides it. Managed already supports durable permission Actions and the shared ToolApproval card. The Java server pins an approval mode in each Session, and the Harness confirms that mode on load. Mode selection must change actual admission, not just its label.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
[English](2026-10-15-managed-approval-selector.md) | [中文](2026-10-15-managed-approval-selector.zh-CN.md)
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录最初的创建选择器切片。其中固定策略和 Shell Full Access 的限制已由[托管审批选择设计](managed-approval-selection.zh-CN.md)取代:后续设计增加能力协商下的 Full Access 和空闲会话策略切换,并保留本文的创建协议。
|
||||
|
||||
## 问题与现状
|
||||
|
||||
本地 WebShell 提供审批策略选择器,托管任务创建界面却隐藏了它。托管模式已有持久化权限 Action 和共享 ToolApproval 卡片。Java 服务为每个会话固定审批策略,Harness 加载时核对该策略。页面选择必须改变真实准入,不能只修改文字。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Managed approval selection and Shell Full Access
|
||||
|
||||
[English](managed-approval-selection.md) | [简体中文](managed-approval-selection.zh-CN.md)
|
||||
|
||||
## Status and scope
|
||||
|
||||
Implemented in this change, based on the Managed WebShell approval selector and model selection work. Linux deployment acceptance remains pending. Built-in, public, Workspace-bound `qwen-code` Sessions may choose `default`, `auto-edit`, or `yolo` at creation and change their next Turn's policy while idle. Stored AgentDefinitions retain their fixed policy. Deployment defaults, Shell admission, tool families, Runtime privileges, and recovery refusal semantics remain unchanged.
|
||||
|
||||
Only the recorded Session owner holding current Workspace OPERATOR permission may change the policy. Active Turns, pending Actions, open operations, retained executions, and recovery work prevent a change. Changing policy preserves history, Workspace, and the draft.
|
||||
|
||||
## Durable state and execution
|
||||
|
||||
The original `ma_session.approval_mode` remains immutable and continues to identify the Harness definition. A nullable `selected_approval_mode` and zero-based `approval_revision` hold the current override. Each newly admitted built-in Turn snapshots its effective mode and revision under the same Session lock used by policy changes. Old Sessions have no override; old Turns without a snapshot use their original definition policy only while the Session has no override. Missing snapshots after a selection refuse execution.
|
||||
|
||||
A change verifies ownership, grants, Workspace facts, idle state, and expected revision before atomically saving the override, incrementing its revision, recording an actor-and-Session-scoped idempotent receipt, and emitting `session.approval.changed`. Identical receipt replay does not change policy again. Concurrent Turn admission and changes are ordered by the Session lock.
|
||||
|
||||
SDK prompt admission carries positive revisions in optional `approvalSelection: {mode, revision}` under `approval-selection/1`. Java always omits this field for revision zero and legacy Turns: the confirmed immutable definition supplies their initial policy, so a Harness capability upgrade cannot change the same prompt's retry identity. New Turns still freeze mode and revision zero in SQL. The Harness records it in the existing `managed-admission` resource. Live and cold duplicate admission compare the snapshot, and execution, Hooks, and recovery read that same committed input. Neither a mutable Session override nor another Turn may replace it. A changed Session cannot execute through a Harness that lacks this feature. Invalid or incomplete snapshots refuse execution rather than falling back.
|
||||
|
||||
`yolo` suppresses ordinary tool approval. Explicit Hook deny/ask decisions retain their semantics and Actions remain available for switchable Sessions. New selectable Full Access definitions persist the configured approval timeout for later asking Turns; legacy definitions without it retain the 10-minute fallback. Shell result capture, durable receipts, cancellation, and uncertain-effect recovery continue through the existing execution chain. Deployment Shell defaults still require `default` or `auto-edit`; an explicit Session choice opens Full Access. Every execution-capable attachment, including passive recovery loads, checks the Harness feature; dedicated cancellation and lifecycle settlement remain available.
|
||||
|
||||
## Interfaces and UI
|
||||
|
||||
Add owner-scoped WebShell `session-approval/query` and `session-approval/change` operations. Query returns `approvalMode`, `approvalRevision`, `availableApprovalModes`, and change support. Change accepts `sessionId`, `approvalMode`, `expectedRevision`, `idempotencyKey`, and `requestId`, and returns the committed mode/revision and `replayed`. Reused keys with different content, stale revisions, and busy Sessions return 409; existing 403/404 isolation rules apply. No public REST change operation is added.
|
||||
|
||||
WebShell summaries expose the current effective `approvalMode`, `approvalRevision`, and immutable `initialApprovalMode`. Pending creation compares the initial policy, not a subsequently changed policy. The existing shared mode menu renders the server's supported modes. While a change is unconfirmed, sending and policy/model/cwd changes are blocked. The browser persists the original intention and idempotency key, retries uncertain results with that intention, and requires a summary readback at least as new as the result before unlocking. Older summaries cannot overwrite a newer revision.
|
||||
|
||||
## Schema and rollout
|
||||
|
||||
Add shared schema V6 after the prerequisite V4 Workspace creation and V5 model selection migrations, including initialization and structural validation. Existing data defaults to revision zero without rewriting the initial policy. Upgrade the Harness first, then migrate and update every Java/Broker reader and writer during a maintenance window, and publish WebShell last. Rollback binaries must understand existing overrides and Turn snapshots; retain new schema and receipts.
|
||||
|
||||
## Verification and acceptance
|
||||
|
||||
Verify the three-mode file/Shell matrix and `default → yolo → auto-edit` in one Session with real effects, output, history, refresh, and restart evidence. Test owner/OPERATOR checks, revoked grants, cross-tenant access, custom definitions, busy and recovery refusal, atomic concurrent send/change, duplicate and lost-response requests, stale tabs, old creation replay, and explicit Hook interaction. SDK and Harness tests cover snapshot parsing, feature refusal, live/cold replay identity, and resumed execution retaining its original policy. MySQL 8.4 tests cover migration, transactions, and durable snapshots. Run affected package tests, build/typecheck/bundle, Java verification, API contract gates, two clean self-audit passes, and independent review. Physical recovery and lease-expiry fixes are outside this feature's acceptance.
|
||||
|
||||
## Local verification and remaining deployment gates
|
||||
|
||||
On macOS with Node 22.22.3, JDK 21.0.8, Maven 3.9.14, and disposable MySQL 8.4.11, the affected TypeScript tests and build/typecheck/bundle, Java SDK/Broker/server verification, schema initialization/validation, and 18 real-MySQL policy transaction tests passed. Independent source review identified and verified the revision-zero replay fix described above. CI results are separate from this local evidence.
|
||||
|
||||
The test engineer verified real Java/Broker/Harness/worker processes and Chrome with a deterministic local model: the three modes produced ordinary approval counts 2/0/1 for a write plus Shell command, exact side effects once, unchanged history, owner/permission/busy/receipt checks, a lost response with refresh and original-request confirmation, retained draft, and a complete menu at 390px. A Harness-only restart retained policy and completed another Turn for both a selected Session and a revision-zero control.
|
||||
|
||||
The macOS process run used the repository's explicit physical-alias SQL test fixture with real mounts and tools, and disabled native/durable Runtime recovery. Full Java restart read the correct selected policy but timed out warming an old non-durable Runtime binding, for both selected and revision-zero Sessions. The unmodified Hosted MySQL profile also failed its first tool Turn with Broker 409 on this host. These are not passing gates or classified regressions. Linux native storage registration, durable whole-JVM recovery, unknown-effect process fault acceptance, and real upstream-model calls still require deployment-lane evidence before release; no lease or Runtime recovery repair is included here.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 托管审批策略切换与 Shell Full Access
|
||||
|
||||
[English](managed-approval-selection.md) | [简体中文](managed-approval-selection.zh-CN.md)
|
||||
|
||||
## 状态与范围
|
||||
|
||||
本次改动已实现,基于托管 WebShell 审批选择器与模型切换改动;Linux 部署验收仍待完成。内置、公开、绑定 Workspace 的 `qwen-code` 会话可在创建时选择 `default`、`auto-edit` 或 `yolo`,并在空闲时修改下一轮使用的策略。已保存 AgentDefinition 保持固定策略。部署默认值、Shell 准入、工具种类、Runtime 权限与恢复拒绝语义保持原状。
|
||||
|
||||
仅会话记录上的 owner 且当前持有 Workspace OPERATOR 权限时可以修改策略。活动回合、未决 Action、未完成操作、保留的未收尾执行和恢复工作均阻止变更。切换保留历史、Workspace 和草稿。
|
||||
|
||||
## 持久状态与执行
|
||||
|
||||
原有 `ma_session.approval_mode` 保持不可变,继续标识 Harness 定义。可空的 `selected_approval_mode` 与从零开始的 `approval_revision` 保存当前覆盖。每个新受理的内置回合,在与策略变更相同的会话锁下冻结有效模式和修订。旧会话没有覆盖;没有快照的旧回合仅在会话没有覆盖时沿用原定义策略,发生切换后缺少快照则拒绝执行。
|
||||
|
||||
变更在一个事务内校验 owner、授权、Workspace 事实、空闲状态和预期修订,再保存覆盖、递增修订、写入按操作者和会话隔离的幂等回执,并产生 `session.approval.changed`。相同回执重放不会再次变更。并发发送和切换由会话锁排序。
|
||||
|
||||
SDK 在 `approval-selection/1` 能力下,通过可选 `approvalSelection: {mode, revision}` 传递正修订。Java 对修订零及旧回合始终省略该字段,由已确认的不可变定义提供初始策略,避免 Harness 能力升级改变同一 prompt 的重试身份;新回合仍在 SQL 冻结模式和修订零。Harness 将其保存到现有 `managed-admission` 资源,驻留和冷重复准入均核对快照,执行、Hook 和恢复从同一份已提交输入读取。可变会话覆盖或其他回合不能替换它。发生过变更的会话不能交给缺少该能力的 Harness 执行。无效或不完整快照拒绝执行,不回退。
|
||||
|
||||
`yolo` 跳过普通工具审批。Hook 的明确 deny/ask 决定保留现有语义,可切换会话继续提供 Actions。新的可切换 Full Access 定义保留部署配置的审批超时,供之后需询问的回合使用;未保存超时的旧定义保留 10 分钟默认值。Shell 结果捕获、持久回执、取消和不确定副作用恢复复用现有执行链。Shell 部署默认值仍要求 `default` 或 `auto-edit`,Full Access 通过会话显式选择开启。包括被动恢复加载在内,所有可能执行的连接均检查 Harness 能力,专用取消和生命周期收尾路径仍可使用。
|
||||
|
||||
## 接口与页面
|
||||
|
||||
增加 owner 范围的 WebShell `session-approval/query` 和 `session-approval/change` 操作。查询返回 `approvalMode`、`approvalRevision`、`availableApprovalModes` 和变更能力。变更接收 `sessionId`、`approvalMode`、`expectedRevision`、`idempotencyKey` 和 `requestId`,返回已提交模式、修订和 `replayed`。同键不同内容、过期修订和忙碌会话返回 409;继续遵守现有 403/404 隔离规则。不新增公开 REST 变更操作。
|
||||
|
||||
WebShell 摘要返回当前有效 `approvalMode`、`approvalRevision` 和不可变 `initialApprovalMode`。待确认创建核对初始策略,避免后来切换导致误判。共享模式菜单使用服务端支持列表。策略变更未确认时,锁住发送和策略、模型、cwd 修改。浏览器持久保存原意图和幂等键,用原意图重试不确定结果,读回至少达到响应修订的摘要后解锁。旧摘要不能覆盖较新的修订。
|
||||
|
||||
## Schema 与上线
|
||||
|
||||
在前置 V4 工作区创建和 V5 模型选择迁移之后添加共享 schema V6,同步初始化和结构校验。旧数据默认修订为零,不改写初始策略。先升级 Harness,再于维护窗口迁移并更新全部 Java/Broker 读写端,最后发布 WebShell。回滚版本必须识别已有覆盖和回合快照,保留新 schema 与回执。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
验证三种模式的文件与 Shell 矩阵,并在同一会话完成 `default → yolo → auto-edit`,核对真实副作用、输出、历史、刷新和重启证据。覆盖 owner/OPERATOR、撤权、跨租户、自定义定义、忙碌与恢复拒绝、并发发送/切换的原子性、重复和丢失响应、过期标签页、旧创建重放及显式 Hook 交互。SDK 与 Harness 测试覆盖快照解析、能力拒绝、驻留/冷重放身份及续跑保留原策略。MySQL 8.4 验证迁移、事务和持久快照。执行相关包测试、build/typecheck/bundle、Java 验证、API 契约门禁、连续两轮干净自审和独立审查。物理恢复和租约过期修复不属于本功能验收。
|
||||
|
||||
## 本地验证与剩余部署门禁
|
||||
|
||||
在 macOS、Node 22.22.3、JDK 21.0.8、Maven 3.9.14 和独立 MySQL 8.4.11 环境中,相关 TypeScript 测试及 build/typecheck/bundle、Java SDK/Broker/server 验证、schema 初始化和校验,以及 18 项真实 MySQL 策略事务测试均已通过。独立源码审查发现并复核了上文的修订零重放修复。CI 结果与这些本地证据分开记录。
|
||||
|
||||
测试工程师使用真实 Java/Broker/Harness/worker 进程、Chrome 和确定性本地模型完成验收:一次文件写入和一条 Shell 命令在三种模式下分别产生 2/0/1 次普通审批,副作用精确且各执行一次,历史保留,owner/权限/忙碌/回执检查通过;响应丢失后刷新并确认原请求、草稿保留和 390px 菜单完整性均通过。仅重启 Harness 后,有覆盖的会话和修订零对照都保留策略并完成新回合。
|
||||
|
||||
macOS 进程测试使用仓库显式物理别名 SQL 测试 fixture,同时保留真实挂载和工具,关闭原生与持久 Runtime 恢复。完整重启 Java 后可读回正确策略,但有覆盖及修订零会话在预热旧的非持久 Runtime 绑定时均超时;未改动的 Hosted MySQL profile 在本机首个工具回合也收到 Broker 409。这些门禁不能算通过,也未归类为本次回归。发布前仍需部署测试通道验证 Linux 原生存储注册、整个 JVM 的持久恢复、不确定副作用的进程故障验收,以及真实上游模型调用;本次不包含租约或 Runtime 恢复修复。
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import {
|
||||
parseHostedApprovalSelection,
|
||||
hostedTurnApproval,
|
||||
} from './hosted-approval-selection.js';
|
||||
|
||||
function committed(selection?: unknown, promptId = 'prompt') {
|
||||
const bytes = Buffer.from(
|
||||
JSON.stringify({
|
||||
promptId,
|
||||
...(selection === undefined ? {} : { approvalSelection: selection }),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
authority: {
|
||||
committedSequence: 1,
|
||||
eventsInSequenceRange: () => [
|
||||
{
|
||||
kind: 'input.accepted',
|
||||
payload: {
|
||||
inputId: 'prompt',
|
||||
source: 'hosted-harness',
|
||||
admissionRef: {
|
||||
resourceId: 'admission',
|
||||
kind: 'managed-admission',
|
||||
schemaVersion: 1,
|
||||
byteLength: bytes.length,
|
||||
digest: '0'.repeat(64),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resources: { read: async () => bytes },
|
||||
} as unknown as ManagedSession;
|
||||
}
|
||||
|
||||
describe('Hosted approval selection', () => {
|
||||
it('permits legacy callers and exact supported modes at revision zero', () => {
|
||||
expect(parseHostedApprovalSelection(undefined)).toBeUndefined();
|
||||
for (const mode of ['default', 'auto-edit', 'yolo'])
|
||||
expect(parseHostedApprovalSelection({ mode, revision: 0 })).toEqual({
|
||||
mode,
|
||||
revision: 0,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ mode: ['yolo'], revision: 1 },
|
||||
{ mode: 'yolo ', revision: 1 },
|
||||
{ mode: 'auto', revision: 1 },
|
||||
{ mode: 'yolo', revision: -1 },
|
||||
{ mode: 'yolo', revision: 1.5 },
|
||||
{ mode: 'yolo', revision: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ mode: 'yolo', revision: '1' },
|
||||
{ mode: 'yolo', revision: 1, extra: true },
|
||||
])('rejects an invalid snapshot %j', (value) => {
|
||||
expect(() => parseHostedApprovalSelection(value)).toThrow(
|
||||
'Invalid Hosted approval selection',
|
||||
);
|
||||
});
|
||||
it('uses the committed Turn override and retains the initial timeout', async () => {
|
||||
const initial = { mode: 'default' as const, timeoutMs: 12345 };
|
||||
expect(
|
||||
await hostedTurnApproval(
|
||||
committed({ mode: 'yolo', revision: 2 }),
|
||||
'prompt',
|
||||
initial,
|
||||
),
|
||||
).toEqual({ mode: 'yolo', timeoutMs: 12345 });
|
||||
expect(await hostedTurnApproval(committed(), 'prompt', initial)).toEqual(
|
||||
initial,
|
||||
);
|
||||
});
|
||||
it('refuses mismatched prompts and conflicting revision-zero snapshots', async () => {
|
||||
const initial = { mode: 'default' as const, timeoutMs: 1000 };
|
||||
await expect(
|
||||
hostedTurnApproval(
|
||||
committed({ mode: 'yolo', revision: 1 }, 'other'),
|
||||
'prompt',
|
||||
initial,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
hostedTurnApproval(
|
||||
committed({ mode: 'yolo', revision: 0 }),
|
||||
'prompt',
|
||||
initial,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
HostedApprovalMode,
|
||||
HostedApprovalSettings,
|
||||
} from './hosted-tool-approval.js';
|
||||
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 interface HostedApprovalSelection {
|
||||
mode: HostedApprovalMode;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export function parseHostedApprovalSelection(
|
||||
value: unknown,
|
||||
): HostedApprovalSelection | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Invalid Hosted approval selection.');
|
||||
const selection = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(selection).length !== 2 ||
|
||||
(selection['mode'] !== 'default' &&
|
||||
selection['mode'] !== 'auto-edit' &&
|
||||
selection['mode'] !== 'yolo') ||
|
||||
typeof selection['revision'] !== 'number' ||
|
||||
!Number.isSafeInteger(selection['revision']) ||
|
||||
selection['revision'] < 0
|
||||
)
|
||||
throw new Error('Invalid Hosted approval selection.');
|
||||
return {
|
||||
mode: selection['mode'] as HostedApprovalMode,
|
||||
revision: selection['revision'],
|
||||
};
|
||||
}
|
||||
|
||||
export async function readHostedTurnApprovalSelection(
|
||||
session: ManagedSession,
|
||||
promptId: string,
|
||||
): Promise<HostedApprovalSelection | undefined> {
|
||||
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 undefined;
|
||||
const rawRef = accepted?.payload['admissionRef'];
|
||||
if (rawRef === undefined) return undefined;
|
||||
const ref = assertManagedSessionDurableRef(
|
||||
rawRef,
|
||||
'approval selection admission',
|
||||
);
|
||||
if (
|
||||
ref.kind !== 'managed-admission' ||
|
||||
ref.schemaVersion !== 1 ||
|
||||
ref.byteLength > 64 * 1024
|
||||
)
|
||||
throw new Error('Invalid Hosted approval selection admission.');
|
||||
const admission: unknown = JSON.parse(
|
||||
(await session.resources.read(ref)).toString('utf8'),
|
||||
);
|
||||
if (!admission || typeof admission !== 'object' || Array.isArray(admission))
|
||||
throw new Error('Invalid Hosted approval selection admission.');
|
||||
const fields = admission as Record<string, unknown>;
|
||||
if (fields['approvalSelection'] === undefined) return undefined;
|
||||
if (fields['promptId'] !== promptId)
|
||||
throw new Error('Hosted approval selection belongs to another input.');
|
||||
return parseHostedApprovalSelection(fields['approvalSelection']);
|
||||
}
|
||||
|
||||
export async function hostedTurnApproval(
|
||||
session: ManagedSession,
|
||||
promptId: string,
|
||||
initial: HostedApprovalSettings | undefined,
|
||||
): Promise<HostedApprovalSettings | undefined> {
|
||||
const selection = await readHostedTurnApprovalSelection(session, promptId);
|
||||
if (!selection) return initial;
|
||||
if (!initial || (selection.revision === 0 && selection.mode !== initial.mode))
|
||||
throw new Error(
|
||||
'Hosted approval selection conflicts with the Session definition.',
|
||||
);
|
||||
return { ...initial, mode: selection.mode };
|
||||
}
|
||||
@@ -50,6 +50,7 @@ describe('Hosted Harness private contract', () => {
|
||||
'hosted_approval_resume_v1',
|
||||
'hosted_model_round_recovery_v1',
|
||||
'model-selection/1',
|
||||
'approval-selection/1',
|
||||
],
|
||||
});
|
||||
expect(Object.isFrozen(contract)).toBe(true);
|
||||
|
||||
@@ -74,6 +74,7 @@ export function createHostedHarnessContract(
|
||||
'hosted_approval_resume_v1',
|
||||
'hosted_model_round_recovery_v1',
|
||||
'model-selection/1',
|
||||
'approval-selection/1',
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1346,6 +1346,71 @@ describe('Hosted Harness no-tool session', () => {
|
||||
).toEqual([request.modelSelection, { modelId: 'model-b', revision: 2 }]);
|
||||
});
|
||||
|
||||
it.each([undefined, { mode: 'yolo', revision: 1 }])(
|
||||
'preserves approval selection %j and refuses changed resident and cold prompt replays',
|
||||
async (approvalSelection) => {
|
||||
let server = await app(true);
|
||||
const created = await headers(supertest(server).post('/session')).send({
|
||||
sessionId: SESSION_ID,
|
||||
sessionScope: 'thread',
|
||||
managedSessionStore: store(),
|
||||
toolProfile: 'hosted-workspace-files/1',
|
||||
approvalMode: 'default',
|
||||
});
|
||||
let clientId = created.body.clientId as string;
|
||||
const prompt = [{ type: 'text', text: 'approval selection' }];
|
||||
const request = {
|
||||
prompt,
|
||||
promptId: PROMPT_ID,
|
||||
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`,
|
||||
approvalSelection,
|
||||
};
|
||||
const send = (body = request) =>
|
||||
headers(supertest(server).post(`/session/${SESSION_ID}/prompt`))
|
||||
.set('X-Qwen-Client-Id', clientId)
|
||||
.send(body);
|
||||
const idle = () =>
|
||||
vi.waitFor(async () => {
|
||||
const result = await headers(
|
||||
supertest(server).get(`/session/${SESSION_ID}/status`),
|
||||
).set('X-Qwen-Client-Id', clientId);
|
||||
expect(result.body.hasActivePrompt).toBe(false);
|
||||
});
|
||||
await send().expect(202);
|
||||
await idle();
|
||||
expect(state.model).toHaveBeenCalledTimes(1);
|
||||
await send({
|
||||
...request,
|
||||
approvalSelection: { mode: 'auto-edit', revision: 2 },
|
||||
}).expect(409);
|
||||
await send().expect(202);
|
||||
await headers(supertest(server).post(`/session/${SESSION_ID}/detach`))
|
||||
.set('X-Qwen-Client-Id', clientId)
|
||||
.expect(204);
|
||||
server = await app(true);
|
||||
const loaded = await headers(
|
||||
supertest(server).post(`/session/${SESSION_ID}/load`),
|
||||
).send({
|
||||
managedSessionStore: store(),
|
||||
toolProfile: 'hosted-workspace-files/1',
|
||||
});
|
||||
expect(loaded.status).toBe(200);
|
||||
clientId = loaded.body.clientId as string;
|
||||
await send({
|
||||
...request,
|
||||
approvalSelection: { mode: 'auto-edit', revision: 2 },
|
||||
}).expect(409);
|
||||
await send().expect(202);
|
||||
await send({
|
||||
...request,
|
||||
promptId: randomUUID(),
|
||||
approvalSelection: { mode: 'auto-edit', revision: 2 },
|
||||
}).expect(202);
|
||||
await idle();
|
||||
expect(state.model).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('restores the accepted model selection before re-running an interrupted model', async () => {
|
||||
const server = await app();
|
||||
const created = await headers(supertest(server).post('/session')).send({
|
||||
@@ -18300,7 +18365,7 @@ describe('Hosted Harness tool approvals', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a yolo tool Session definition unchanged', async () => {
|
||||
it('pins valid approval settings for a selectable yolo Session', async () => {
|
||||
const created = await headers(
|
||||
supertest(await app(true)).post('/session'),
|
||||
).send({
|
||||
@@ -18314,7 +18379,13 @@ describe('Hosted Harness tool approvals', () => {
|
||||
expect(created.status).toBe(200);
|
||||
expect(created.body.approvalMode).toBe('yolo');
|
||||
expect(await definitions()).toEqual([
|
||||
{ engine: 'managed', sessionId: SESSION_ID, toolProfile: files },
|
||||
{
|
||||
engine: 'managed',
|
||||
sessionId: SESSION_ID,
|
||||
toolProfile: files,
|
||||
approvalMode: 'yolo',
|
||||
approvalTimeoutMs: HOSTED_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
parseHostedApprovalSelection,
|
||||
readHostedTurnApprovalSelection,
|
||||
hostedTurnApproval,
|
||||
type HostedApprovalSelection,
|
||||
} from './hosted-approval-selection.js';
|
||||
import {
|
||||
findHostedModelRequest,
|
||||
findHostedCommittedModelOutput,
|
||||
@@ -3094,7 +3100,11 @@ async function executeHostedTurn(
|
||||
session.publication,
|
||||
session.shell,
|
||||
session.approval && {
|
||||
settings: session.approval,
|
||||
settings: (await hostedTurnApproval(
|
||||
session.managed,
|
||||
promptId,
|
||||
session.approval,
|
||||
))!,
|
||||
waiters: session.waiters,
|
||||
},
|
||||
{
|
||||
@@ -4198,7 +4208,14 @@ export function registerHostedHarnessSessionRoutes(
|
||||
...(isHostedWorkspaceShellProfile(toolProfile)
|
||||
? { captureBytes }
|
||||
: {}),
|
||||
...(approval ? hostedApprovalDefinition(approval) : {}),
|
||||
...(approval
|
||||
? hostedApprovalDefinition(
|
||||
approval,
|
||||
!agentDefinition &&
|
||||
(toolProfile === HOSTED_WORKSPACE_FILE_PROFILE ||
|
||||
isHostedWorkspaceShellProfile(toolProfile)),
|
||||
)
|
||||
: {}),
|
||||
// H4b: a child's ancestry persists with its definition,
|
||||
// so a load answers its depth without a second channel.
|
||||
...(lineage ? { lineage } : {}),
|
||||
@@ -6050,6 +6067,28 @@ export function registerHostedHarnessSessionRoutes(
|
||||
const prompt = body?.['prompt'];
|
||||
const digest = body?.['payloadDigest'];
|
||||
const deadlineMs = body?.['deadlineMs'];
|
||||
let approvalSelection: HostedApprovalSelection | undefined;
|
||||
try {
|
||||
approvalSelection = parseHostedApprovalSelection(
|
||||
body?.['approvalSelection'],
|
||||
);
|
||||
if (
|
||||
approvalSelection &&
|
||||
(session.agentDefinition ||
|
||||
!session.approval ||
|
||||
![
|
||||
'hosted-workspace-files/1',
|
||||
'hosted-workspace-shell/1',
|
||||
'hosted-workspace-files/2',
|
||||
'hosted-workspace-shell/2',
|
||||
].includes(session.toolProfile ?? '') ||
|
||||
(approvalSelection.revision === 0 &&
|
||||
approvalSelection.mode !== session.approval.mode))
|
||||
)
|
||||
throw new Error('Approval selection is unavailable for this Session.');
|
||||
} catch {
|
||||
return error(res, 400, 'invalid_hosted_approval_selection');
|
||||
}
|
||||
let modelSelection: HostedModelSelection | undefined;
|
||||
try {
|
||||
modelSelection = parseHostedModelSelection(body?.['modelSelection']);
|
||||
@@ -6066,7 +6105,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
// replay answers through the ordinary dedup machinery; every other
|
||||
// shape keeps the pre-change refusal.
|
||||
if (workflowLaunch !== undefined) {
|
||||
if (modelSelection !== undefined)
|
||||
if (modelSelection !== undefined || approvalSelection !== undefined)
|
||||
return error(res, 400, 'invalid_hosted_model_selection');
|
||||
const journaledInputs = session.managed.authority
|
||||
.eventsInSequenceRange(1, session.managed.authority.committedSequence)
|
||||
@@ -6133,6 +6172,10 @@ export function registerHostedHarnessSessionRoutes(
|
||||
!isDeepStrictEqual(
|
||||
await readHostedTurnModelSelection(session.managed, promptId),
|
||||
modelSelection,
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
await readHostedTurnApprovalSelection(session.managed, promptId),
|
||||
approvalSelection,
|
||||
)
|
||||
)
|
||||
return error(res, 409, 'hosted_prompt_conflict');
|
||||
@@ -6290,6 +6333,12 @@ export function registerHostedHarnessSessionRoutes(
|
||||
acceptedAdmission['modelSelection'],
|
||||
),
|
||||
modelSelection,
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
parseHostedApprovalSelection(
|
||||
acceptedAdmission?.['approvalSelection'],
|
||||
),
|
||||
approvalSelection,
|
||||
)
|
||||
) {
|
||||
if (!res.headersSent) error(res, 409, 'hosted_prompt_conflict');
|
||||
@@ -6434,6 +6483,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
promptId,
|
||||
digest,
|
||||
...(modelSelection ? { modelSelection } : {}),
|
||||
...(approvalSelection ? { approvalSelection } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -8208,7 +8258,11 @@ export function registerHostedHarnessSessionRoutes(
|
||||
session.publication,
|
||||
session.shell,
|
||||
session.approval && {
|
||||
settings: session.approval,
|
||||
settings: (await hostedTurnApproval(
|
||||
session.managed,
|
||||
promptId,
|
||||
session.approval,
|
||||
))!,
|
||||
waiters: session.waiters,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
hostedApprovalAsks,
|
||||
hostedApprovalDefinition,
|
||||
parseHostedApprovalSettings,
|
||||
readHostedApprovalDefinition,
|
||||
} from './hosted-tool-approval.js';
|
||||
import {
|
||||
HOSTED_WORKSPACE_READ_PROFILE,
|
||||
@@ -137,6 +138,14 @@ describe('Hosted tool approval settings', () => {
|
||||
hostedApprovalDefinition({ mode: 'auto-edit', timeoutMs: 5_000 }),
|
||||
).toEqual({ approvalMode: 'auto-edit', approvalTimeoutMs: 5_000 });
|
||||
});
|
||||
|
||||
it('retains a selectable Full Access timeout through a cold definition read', () => {
|
||||
const settings = parseHostedApprovalSettings('yolo', 5_000)!;
|
||||
expect(settings.timeoutMs).toBe(5_000);
|
||||
expect(
|
||||
readHostedApprovalDefinition(hostedApprovalDefinition(settings, true)),
|
||||
).toEqual({ mode: 'yolo', timeoutMs: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hosted approval decisions', () => {
|
||||
|
||||
@@ -66,17 +66,16 @@ const SEARCH_PREAPPROVED_TOOLS: readonly string[] = ['glob'];
|
||||
/**
|
||||
* Reads a tool profile's approval settings. `plan` needs its own planning
|
||||
* semantics and `auto` a classifier, which the Hosted path does not have, so
|
||||
* both are refused like any unknown mode. `yolo` never waits, so it ignores
|
||||
* the timeout.
|
||||
* both are refused like any unknown mode. `yolo` retains a valid timeout for
|
||||
* a later policy selection, while preserving its legacy invalid-timeout fallback.
|
||||
*/
|
||||
export function parseHostedApprovalSettings(
|
||||
mode: unknown,
|
||||
timeoutMs: unknown,
|
||||
): HostedApprovalSettings | undefined {
|
||||
const parsed = mode === undefined ? 'yolo' : mode;
|
||||
if (parsed === 'yolo')
|
||||
return { mode: parsed, timeoutMs: HOSTED_APPROVAL_TIMEOUT_MS };
|
||||
if (parsed !== 'default' && parsed !== 'auto-edit') return undefined;
|
||||
if (parsed !== 'yolo' && parsed !== 'default' && parsed !== 'auto-edit')
|
||||
return undefined;
|
||||
if (
|
||||
timeoutMs !== undefined &&
|
||||
(typeof timeoutMs !== 'number' ||
|
||||
@@ -84,7 +83,9 @@ export function parseHostedApprovalSettings(
|
||||
timeoutMs < MIN_APPROVAL_TIMEOUT_MS ||
|
||||
timeoutMs > MAX_APPROVAL_TIMEOUT_MS)
|
||||
)
|
||||
return undefined;
|
||||
return parsed === 'yolo'
|
||||
? { mode: parsed, timeoutMs: HOSTED_APPROVAL_TIMEOUT_MS }
|
||||
: undefined;
|
||||
return {
|
||||
mode: parsed,
|
||||
timeoutMs: (timeoutMs as number | undefined) ?? HOSTED_APPROVAL_TIMEOUT_MS,
|
||||
@@ -104,11 +105,12 @@ export function readHostedApprovalDefinition(
|
||||
return parseHostedApprovalSettings(mode, timeoutMs);
|
||||
}
|
||||
|
||||
/** Definition fields that pin a mode which asks; `yolo` adds none. */
|
||||
/** Legacy fixed `yolo` definitions stay empty; selectable ones retain the timeout. */
|
||||
export function hostedApprovalDefinition(
|
||||
settings: HostedApprovalSettings,
|
||||
selectable = false,
|
||||
): Record<string, unknown> {
|
||||
return settings.mode === 'yolo'
|
||||
return settings.mode === 'yolo' && !selectable
|
||||
? {}
|
||||
: { approvalMode: settings.mode, approvalTimeoutMs: settings.timeoutMs };
|
||||
}
|
||||
|
||||
@@ -681,6 +681,7 @@ async function shellReceiptScenario(
|
||||
| 'sentinel'
|
||||
| 'lost-admission'
|
||||
| 'lost-reserve',
|
||||
approval?: { mode: HostedApprovalMode },
|
||||
) {
|
||||
const shellCall = {
|
||||
...calls[0],
|
||||
@@ -850,6 +851,10 @@ async function shellReceiptScenario(
|
||||
mode !== 'large' ||
|
||||
Buffer.byteLength(JSON.stringify(messageParts)) <= 64 * 1024,
|
||||
{ owner, captureBytes: 1024 * 1024 },
|
||||
undefined,
|
||||
approval
|
||||
? { settings: { mode: approval.mode, timeoutMs: 1000 }, waiters }
|
||||
: undefined,
|
||||
);
|
||||
const execution = shellTurn.execute(
|
||||
[shellCall],
|
||||
@@ -950,6 +955,11 @@ async function shellReceiptScenario(
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
it('runs Full Access Shell without ordinary Actions through durable result and replay acceptance', async () => {
|
||||
await shellReceiptScenario('normal', { mode: 'yolo' });
|
||||
expect(actionIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'normal',
|
||||
'abandoned',
|
||||
@@ -961,7 +971,7 @@ it.each([
|
||||
'lost-reserve',
|
||||
] as const)(
|
||||
'uses only the original Shell publication after Broker %s',
|
||||
shellReceiptScenario,
|
||||
(mode) => shellReceiptScenario(mode),
|
||||
);
|
||||
|
||||
it('records a durable receipt for a proven unstarted Shell', async () => {
|
||||
|
||||
@@ -623,12 +623,17 @@ or above, and that read grant as well. A Shell Session is the exception:
|
||||
only its recorded owner may answer, because that answer runs a command. Responses are durable, idempotent
|
||||
operations; their final result follows the committed Harness decision.
|
||||
`QWEN_MANAGED_AGENT_APPROVAL_TIMEOUT` defaults to `10m` and accepts `1s` to `24h`.
|
||||
The approval mode is pinned at Session creation and must be confirmed by the
|
||||
Harness on creation and load.
|
||||
The initial approval mode is pinned at Session creation and must be confirmed by the
|
||||
Harness on creation and load. Built-in Workspace Sessions can select a different
|
||||
policy for later Turns while retaining the original definition.
|
||||
|
||||
For new Workspace-bound WebShell Sessions, the shared composer queries `POST /api/agent/web-shell/v1/session-options/query` with `agentId` using the authenticated tenant actor. The response contains the effective `approvalMode` and `availableApprovalModes`; Workspace discovery advertises `approvalModeSelection` for this contract. Built-in Shell Sessions allow `default` and `auto-edit`; file-only Sessions additionally allow `yolo`. Hosted execution does not support classifier `auto` or `plan`. An AgentDefinition exposes only its compiled policy, which a creation request cannot override.
|
||||
For new Workspace-bound WebShell Sessions, the shared composer queries `POST /api/agent/web-shell/v1/session-options/query` with `agentId` using the authenticated tenant actor. The response contains the effective `approvalMode` and `availableApprovalModes`; Workspace discovery advertises `approvalModeSelection` for this contract. Built-in Sessions allow `default`, `auto-edit`, and `yolo` (Full Access). Shell Full Access additionally requires the Harness feature `approval-selection/1`; without it Shell offers only `default` and `auto-edit`. The Shell deployment opt-in and its deployment default remain unchanged. Hosted execution does not support classifier `auto` or `plan`. An AgentDefinition exposes only its compiled policy, which a creation request cannot override.
|
||||
|
||||
The WebShell create request may include `approvalMode` only with a Workspace binding. Omission preserves the existing default and request digest; explicit selection is persisted in the Session and joins its idempotency digest. Retrying the same key reads the original pin even if deployment defaults or the AgentDefinition changed. Workspace-bound and definition-pinned Session summaries expose the saved mode, and existing Sessions cannot change it. Legacy unbound built-in Sessions omit this metadata because their execution policy is supplied by the deployment. The public REST create contract is unchanged.
|
||||
The WebShell create request may include `approvalMode` only with a Workspace binding. Omission preserves the existing default and request digest; explicit selection is persisted in the Session and joins its idempotency digest. Retrying the same key reads the original pin even if deployment defaults, the AgentDefinition, or a later Session selection changed. Workspace-bound and definition-pinned Session summaries expose the current `approvalMode`, immutable `initialApprovalMode`, and `approvalRevision`. Legacy unbound built-in Sessions omit this metadata because their execution policy is supplied by the deployment. The public REST create contract is unchanged.
|
||||
|
||||
Built-in Workspace Sessions can change their next Turn's approval policy when the Harness advertises `approval-selection/1`. `POST /api/agent/web-shell/v1/session-approval/query` returns the current mode, revision, available modes, and support. `POST /api/agent/web-shell/v1/session-approval/change` accepts `sessionId`, `approvalMode`, `expectedRevision`, `idempotencyKey`, and `requestId`. Only the recorded Session owner with current Workspace OPERATOR or OWNER access may change it or replay a receipt. The Session must be idle, with no pending approval, context/lifecycle operation, retained Runtime, or recovery work. Identical retries return the original result without another revision; different content under the same key and stale revisions conflict. Stored AgentDefinitions remain fixed. There is no public REST mutation endpoint.
|
||||
|
||||
Schema V6 preserves the original policy and adds a current override plus per-Turn approval snapshots. Turn admission and policy changes share the Session row lock, so retries and recovery execute the admitted snapshot. Revision zero and legacy Turns consistently omit the private `approvalSelection` field and execute the confirmed immutable definition, preserving prompt retry identity across Harness capability upgrades; positive revisions always send the saved Turn snapshot. `yolo` skips ordinary file and Shell approvals, while explicit Hook ask/deny decisions retain their existing behavior. The configured approval timeout persists for new selectable Full Access Sessions and survives a later switch back to asking. Legacy Full Access definitions without a timeout keep the existing 10-minute fallback. WebShell retains an uncertain change with its original request and key across refresh, blocks send/model/cwd changes until receipt readback, and ignores older policy revisions. A Session with an override cannot execute through an older Harness. Deploy the updated Harness first, upgrade schema and all Java/Broker readers and writers during a maintenance window, then release WebShell; rollback binaries must understand the saved override and snapshots. See the [approval selection design](../../docs/design/managed-approval-selection.md).
|
||||
|
||||
Workspace-bound WebShell Session summaries can include `modelContext` with the actual observed `modelId`, optional latest-request `inputTokens` and resolved `contextWindowSize`, and `observedAt`. The Hosted CLI records capacity beside successful provider usage; the Java Session Store projects only committed main-model attempts into one private latest-resource pointer. Lists read these observations in one batch without scanning journal history. Older or invalid usage advances the pointer but leaves missing values unknown; cumulative billing totals and inferred capacities never substitute for them. The shared usage popover does not offer manual compression. New tasks continue to select published AgentDefinition presets. This observation projection uses existing SQL tables and resource-reference cleanup without changing the recovery format.
|
||||
|
||||
@@ -638,7 +643,7 @@ Schema V5 adds the current selection to the Session and copies it into each newl
|
||||
|
||||
Submit `agent_id: "qwen-code"`, the existing `workspace` selection and `input`
|
||||
through `POST /v1/agents/sessions`. The server persists
|
||||
`hosted-workspace-shell/1` with mandatory `default`/`auto-edit` approval when the
|
||||
`hosted-workspace-shell/1` with the deployment default `default`/`auto-edit` policy when the
|
||||
Shell deployment opt-in is enabled, otherwise `hosted-workspace-files/1` with the
|
||||
configured file approval mode. This also applies to empty bound creation. The
|
||||
Session Store uses the persisted Workspace ID; public callers cannot choose the
|
||||
|
||||
+20
-2
@@ -98,7 +98,8 @@ public final class ApiModels {
|
||||
boolean workspaceTurns, boolean sessionClose, boolean sessionArchive,
|
||||
boolean sessionUnarchive, boolean sessionDelete, boolean cwdChange,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean foregroundShell,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean modelChange) {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean modelChange,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean approvalModeChange) {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@@ -246,6 +247,22 @@ public final class ApiModels {
|
||||
public record WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes) {
|
||||
}
|
||||
|
||||
public record WebShellApprovalSelection(String mode, long revision) {}
|
||||
|
||||
public record WebShellSessionApproval(String approvalMode, long approvalRevision,
|
||||
List<String> availableApprovalModes, boolean supported) {}
|
||||
|
||||
public record WebShellSessionApprovalRequest(@NotBlank @Size(max = 128) String sessionId) {}
|
||||
|
||||
public record WebShellChangeApprovalRequest(@Size(max = 128) String requestId,
|
||||
@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) {}
|
||||
|
||||
public record WebShellApprovalChangeResult(String sessionId, String approvalMode,
|
||||
long approvalRevision, boolean replayed) {}
|
||||
|
||||
public record WebShellModelOption(String modelId, String label) {}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@@ -393,7 +410,8 @@ public final class ApiModels {
|
||||
WebShellTurn activeTurn, Object environment, long lastSequence,
|
||||
WebShellWorkspace workspace,
|
||||
WebShellSessionCapabilities capabilities, String approvalMode,
|
||||
WebShellModelContext modelContext, WebShellModelSelection modelSelection) {
|
||||
WebShellModelContext modelContext, WebShellModelSelection modelSelection,
|
||||
Long approvalRevision, String initialApprovalMode) {
|
||||
}
|
||||
|
||||
/** H6b: the definition fields a create or revise request carries. */
|
||||
|
||||
+15
@@ -265,6 +265,21 @@ public class WebShellAgentController {
|
||||
request.expectedContextRevision()));
|
||||
}
|
||||
|
||||
@PostMapping("/session-approval/query")
|
||||
public ApiModels.WebShellSessionApproval querySessionApproval(TenantContext tenant,
|
||||
@Valid @RequestBody ApiModels.WebShellSessionApprovalRequest request) {
|
||||
return service.queryWebShellSessionApproval(tenant.tenantId(), tenant.actorId(), request.sessionId());
|
||||
}
|
||||
|
||||
@PostMapping("/session-approval/change")
|
||||
public ApiModels.WebShellApprovalChangeResult changeSessionApproval(TenantContext tenant,
|
||||
@Valid @RequestBody ApiModels.WebShellChangeApprovalRequest request,
|
||||
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());
|
||||
}
|
||||
|
||||
@PostMapping("/session-models/query")
|
||||
public WebShellSessionModels querySessionModels(TenantContext tenant,
|
||||
@Valid @RequestBody WebShellSessionModelsRequest request) {
|
||||
|
||||
+2
@@ -9,6 +9,8 @@ import java.util.Map;
|
||||
public interface HarnessConnector extends AutoCloseable {
|
||||
boolean isAvailable();
|
||||
|
||||
default boolean supportsApprovalSelection() { return false; }
|
||||
|
||||
default boolean supportsModelSelection() { return false; }
|
||||
|
||||
default boolean isWorkspaceFilesAvailable() {
|
||||
|
||||
+52
-2
@@ -345,6 +345,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
workspaceExecution.authorize(session);
|
||||
}
|
||||
}
|
||||
requireApprovalSelectionSupport(session);
|
||||
AttachmentKey key = new AttachmentKey(tenantId, sessionId);
|
||||
HarnessSessionRef attached = passiveManagedRuntimeRecovery && loadExisting
|
||||
? load(session, true) : attachments.get(key);
|
||||
@@ -410,6 +411,15 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsApprovalSelection() {
|
||||
try {
|
||||
return client().capabilities().getFeatures().contains("approval-selection/1");
|
||||
} catch (RuntimeException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsModelSelection() {
|
||||
try {
|
||||
@@ -435,6 +445,15 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
}
|
||||
builder.modelSelection(selection.modelId(), selection.revision());
|
||||
}
|
||||
var policy = sessions.findTurnApprovalSelection(tenantId, sessionId, promptId);
|
||||
// Revision zero uses the immutable definition on every Harness generation.
|
||||
if (policy != null && policy.revision() > 0) {
|
||||
if (supportsApprovalSelection()) {
|
||||
builder.approvalSelection(policy.mode(), policy.revision());
|
||||
} else {
|
||||
throw new IllegalStateException("Hosted approval selection is unavailable");
|
||||
}
|
||||
}
|
||||
input.forEach(builder::addContent);
|
||||
PromptReceipt receipt = client().submitTurn(builder.build());
|
||||
return new Admission(receipt.getLastEventId(),
|
||||
@@ -458,6 +477,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
String sessionId, String promptId, String checkpointId,
|
||||
String activationId) {
|
||||
requireReadyForNewWork(tenantId, sessionId, false);
|
||||
sessions.findTurnApprovalSelection(tenantId, sessionId, promptId);
|
||||
// Resolve the attachment BEFORE fetching the client: the resolution
|
||||
// may block on a create/load round trip, and an adoption closing the
|
||||
// captured client during that window would strand this call on a
|
||||
@@ -926,10 +946,12 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
workspaceExecution.authorize(session);
|
||||
}
|
||||
}
|
||||
requireApprovalSelectionSupport(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private HarnessSessionRef create(SessionRecord session, boolean passiveManagedRuntimeRecovery) {
|
||||
requireApprovalSelectionSupport(session);
|
||||
try {
|
||||
String profile = toolProfile(session);
|
||||
CreateHarnessSession.Builder builder =
|
||||
@@ -1005,6 +1027,9 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
private HarnessSessionRef load(SessionRecord session,
|
||||
boolean passiveManagedRuntimeRecovery,
|
||||
boolean driveRuntimeRecovery, boolean cancellationTakeover) {
|
||||
if (!cancellationTakeover) {
|
||||
requireApprovalSelectionSupport(session);
|
||||
}
|
||||
String profile = toolProfile(session);
|
||||
ManagedSessionStoreConnection store = managedSessionStore(session);
|
||||
return client().loadSession(new LoadHarnessSession(session.sessionId(), store,
|
||||
@@ -1129,6 +1154,9 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
String sessionId, boolean cancellation) {
|
||||
SessionRecord session = requireRecoverableSession(tenantId, sessionId,
|
||||
cancellation);
|
||||
if (!cancellation) {
|
||||
requireApprovalSelectionSupport(session);
|
||||
}
|
||||
AttachmentKey key = new AttachmentKey(tenantId, sessionId);
|
||||
HarnessSessionRef cached = attachments.get(key);
|
||||
boolean minted = cached == null;
|
||||
@@ -1165,9 +1193,31 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
|
||||
private void requireShellApproval(SessionRecord session) {
|
||||
if (session.workspace() != null && WorkspaceToolProfiles.isPublicShell(session.toolProfile())
|
||||
&& (actions == null || !WorkspaceToolProfiles.requiresApproval(
|
||||
&& (actions == null || !WorkspaceToolProfiles.supportsApprovalMode(
|
||||
actions.approvalMode(session.tenantId(), session.sessionId())))) {
|
||||
throw new IllegalStateException("Hosted Workspace Shell requires persisted default or auto-edit approval mode");
|
||||
throw new IllegalStateException("Hosted Workspace Shell requires a valid persisted approval mode");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireApprovalSelectionSupport(SessionRecord session) {
|
||||
if (!WorkspaceToolProfiles.supportsApprovalSelection(session)) {
|
||||
return;
|
||||
}
|
||||
var selected = sessions.findApprovalSelections(session.tenantId(), List.of(session.sessionId())).get(session.sessionId());
|
||||
boolean changed = selected != null && selected.revision() > 0;
|
||||
if (!supportsApprovalSelection() && (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) {
|
||||
throw new IllegalStateException("The active Turn approval snapshot is missing");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+52
-11
@@ -14,6 +14,8 @@ import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicSession;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicTurn;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicWorkspace;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.SessionCapabilities;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalChangeResult;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalSelection;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellContentPart;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellEvent;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellItem;
|
||||
@@ -22,6 +24,7 @@ import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelContext;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelSelection;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellPage;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSession;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionApproval;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionCapabilities;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionModels;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionOptions;
|
||||
@@ -134,7 +137,7 @@ public class ManagedAgentService {
|
||||
|
||||
private boolean hasActions(SessionRecord session) {
|
||||
return session.workspace() != null && session.deletedAt() == null
|
||||
&& !"yolo".equals(session.approvalMode());
|
||||
&& ((WorkspaceToolProfiles.supportsApprovalSelection(session) && harness.supportsApprovalSelection()) || !"yolo".equals(session.approvalMode()));
|
||||
}
|
||||
|
||||
private BooleanSupplier artifactReadsEnabled = () -> false;
|
||||
@@ -222,7 +225,11 @@ public class ManagedAgentService {
|
||||
public WebShellSessionOptions queryWebShellSessionOptions(
|
||||
String tenantId, String actorId, String agentId) {
|
||||
requireModelActor(actorId);
|
||||
return store.workspaceSessionOptions(tenantId, agentId);
|
||||
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 options;
|
||||
}
|
||||
|
||||
public WebShellSessionModels queryWebShellSessionModels(String tenantId, String actorId,
|
||||
@@ -246,6 +253,29 @@ public class ManagedAgentService {
|
||||
expectedRevision, harness.supportsModelSelection());
|
||||
}
|
||||
|
||||
public WebShellSessionApproval queryWebShellSessionApproval(String tenantId, String actorId, String sessionId) {
|
||||
requireModelActor(actorId);
|
||||
var summary = getWebShellSession(tenantId, actorId, sessionId);
|
||||
if (summary.workspace() == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST,
|
||||
"unsupported_feature", "Approval selection requires a Workspace Session");
|
||||
}
|
||||
boolean supported = Boolean.TRUE.equals(summary.capabilities().approvalModeChange());
|
||||
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);
|
||||
}
|
||||
|
||||
public WebShellApprovalChangeResult changeWebShellSessionApproval(String tenantId, String actorId,
|
||||
String sessionId, String key, String mode, long expectedRevision) {
|
||||
requireModelActor(actorId);
|
||||
validateIdempotencyKey(key);
|
||||
String digest = digests.digest(Map.of("sessionId", sessionId, "mode", mode, "expectedRevision", expectedRevision));
|
||||
return store.changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode,
|
||||
expectedRevision, harness.supportsApprovalSelection());
|
||||
}
|
||||
|
||||
private static void requireModelActor(String actorId) {
|
||||
if (actorId == null || actorId.isEmpty()) {
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "actor_required", "A trusted actor is required");
|
||||
@@ -302,7 +332,7 @@ public class ManagedAgentService {
|
||||
: store.insertWorkspaceSessionCommand(tenantId,
|
||||
actorId, idempotencyKey, requestDigest, agentId,
|
||||
agentRevision, effectiveTitle, input, payloadDigest,
|
||||
selection, approvalMode);
|
||||
selection, approvalMode, harness.supportsApprovalSelection());
|
||||
} catch (DuplicateKeyException error) {
|
||||
admission = store.replayWorkspaceSessionCommand(tenantId,
|
||||
actorId, idempotencyKey, requestDigest);
|
||||
@@ -1059,6 +1089,11 @@ public class ManagedAgentService {
|
||||
.findLatestEnvironmentEvents(tenantId, latestTurns);
|
||||
List<String> boundIds = sessions.stream().filter(session -> session.workspace() != null)
|
||||
.map(SessionRecord::sessionId).toList();
|
||||
List<String> approvalIds = sessions.stream().filter(WorkspaceToolProfiles::supportsApprovalSelection)
|
||||
.map(SessionRecord::sessionId).toList();
|
||||
Map<String, WebShellApprovalSelection> approvalSelections = store.findApprovalSelections(tenantId, approvalIds);
|
||||
Set<String> approvalOwners = store.findApprovalOwners(tenantId, actorId, approvalIds);
|
||||
boolean approvalSupported = !approvalIds.isEmpty() && harness.supportsApprovalSelection();
|
||||
Map<String, WebShellModelSelection> modelSelections = store.findModelSelections(tenantId, boundIds);
|
||||
Map<String, WebShellModelContext> modelContexts = store.findModelContexts(tenantId, boundIds);
|
||||
boolean modelSelectionSupported = !store.modelOptions().isEmpty() && harness.supportsModelSelection();
|
||||
@@ -1103,7 +1138,8 @@ public class ManagedAgentService {
|
||||
maySubmitWorkspaceTurn(session, grants, executable),
|
||||
mayChangeCwd(session, grants, executable),
|
||||
modelContexts.get(session.sessionId()),
|
||||
modelSelections.get(session.sessionId()), modelSelectionSupported))
|
||||
modelSelections.get(session.sessionId()), modelSelectionSupported,
|
||||
approvalSelections.get(session.sessionId()), approvalSupported && approvalOwners.contains(session.sessionId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -1128,10 +1164,12 @@ public class ManagedAgentService {
|
||||
TurnSummary latestTurn, EventRecord environmentEvent,
|
||||
boolean retention, boolean maySubmit, boolean mayChangeCwd,
|
||||
WebShellModelContext modelContext, WebShellModelSelection modelSelection,
|
||||
boolean modelSelectionSupported) {
|
||||
boolean modelSelectionSupported, WebShellApprovalSelection approvalSelection, boolean approvalSupported) {
|
||||
boolean visible = session.deletedAt() == null;
|
||||
boolean close = visible && supportsClose(session);
|
||||
boolean keep = visible && retention;
|
||||
boolean approvalExecutable = approvalSelection == null || approvalSelection.revision() == 0
|
||||
|| harness.supportsApprovalSelection();
|
||||
return new WebShellSession(
|
||||
session.sessionId(),
|
||||
session.title(),
|
||||
@@ -1149,13 +1187,15 @@ public class ManagedAgentService {
|
||||
// capabilities gate 404s once deletedAt is set.
|
||||
new WebShellSessionCapabilities(visible,
|
||||
hasArtifacts(session),
|
||||
hasActions(session), maySubmit, close, keep, keep,
|
||||
hasActions(session), maySubmit && approvalExecutable, close, keep, keep,
|
||||
visible && supportsDelete(session, retention),
|
||||
mayChangeCwd, foregroundShell(session),
|
||||
visible && modelSelectionSupported && mayChangeCwd),
|
||||
mayChangeCwd && approvalExecutable, foregroundShell(session),
|
||||
visible && modelSelectionSupported && mayChangeCwd,
|
||||
visible && approvalSupported && mayChangeCwd && !Boolean.FALSE.equals(foregroundShell(session))),
|
||||
session.workspace() != null || session.agentDefinitionDigest() != null
|
||||
? session.approvalMode() : null,
|
||||
modelContext, modelSelection);
|
||||
? approvalSelection == null ? session.approvalMode() : approvalSelection.mode() : null,
|
||||
modelContext, modelSelection, approvalSelection == null ? null : approvalSelection.revision(),
|
||||
session.workspace() != null || session.agentDefinitionDigest() != null ? session.approvalMode() : null);
|
||||
}
|
||||
|
||||
private boolean supportsDelete(SessionRecord session, boolean retention) {
|
||||
@@ -1171,7 +1211,8 @@ public class ManagedAgentService {
|
||||
return null;
|
||||
}
|
||||
return session.deletedAt() == null && store.workspaceFilesEnabled() && store.workspaceShellEnabled()
|
||||
&& WorkspaceToolProfiles.requiresApproval(session.approvalMode());
|
||||
&& WorkspaceToolProfiles.supportsApprovalMode(session.approvalMode())
|
||||
&& (!"yolo".equals(session.approvalMode()) || harness.supportsApprovalSelection());
|
||||
}
|
||||
|
||||
private static WebShellWorkspace webShellWorkspace(SessionRecord session) {
|
||||
|
||||
+28
@@ -1,5 +1,7 @@
|
||||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalChangeResult;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalSelection;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelChangeResult;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelContext;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelOption;
|
||||
@@ -131,6 +133,17 @@ public interface AgentStateStore {
|
||||
requestDigest, agentId, requestedRevision, title, input, payloadDigest, selection);
|
||||
}
|
||||
|
||||
@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) {
|
||||
return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey,
|
||||
requestDigest, agentId, requestedRevision, title, input, payloadDigest,
|
||||
selection, approvalMode);
|
||||
}
|
||||
|
||||
default WebShellSessionOptions workspaceSessionOptions(
|
||||
String tenantId, String agentId) {
|
||||
throw new UnsupportedOperationException("Approval mode selection is unavailable");
|
||||
@@ -513,6 +526,21 @@ public interface AgentStateStore {
|
||||
throw new UnsupportedOperationException("Model selection is unavailable");
|
||||
}
|
||||
|
||||
default Map<String, WebShellApprovalSelection> findApprovalSelections(
|
||||
String tenantId, List<String> sessionIds) { return Map.of(); }
|
||||
|
||||
default Set<String> findApprovalOwners(String tenantId, String actorId,
|
||||
List<String> sessionIds) { return Set.of(); }
|
||||
|
||||
default WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId,
|
||||
String sessionId, String key, String digest, String mode, long expectedRevision,
|
||||
boolean supported) {
|
||||
throw new UnsupportedOperationException("Approval selection is unavailable");
|
||||
}
|
||||
|
||||
default WebShellApprovalSelection findTurnApprovalSelection(String tenantId, String sessionId,
|
||||
String promptId) { return null; }
|
||||
|
||||
default WebShellModelSelection findTurnModelSelection(String tenantId, String sessionId,
|
||||
String promptId) { return null; }
|
||||
|
||||
|
||||
+183
-33
@@ -1,6 +1,8 @@
|
||||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalChangeResult;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalSelection;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelChangeResult;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelContext;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellModelOption;
|
||||
@@ -357,6 +359,18 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
String agentId, String requestedRevision, String title,
|
||||
List<Map<String, Object>> input, String payloadDigest,
|
||||
WorkspaceSelection selection, String requestedApprovalMode) {
|
||||
return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey,
|
||||
requestDigest, agentId, requestedRevision, title, input, payloadDigest,
|
||||
selection, requestedApprovalMode, 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) {
|
||||
if (!input.isEmpty() && !workspaceFilesEnabled) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
@@ -369,7 +383,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
requestDigest, existing.getFirst(), false);
|
||||
}
|
||||
AgentPin pin = selectApprovalMode(resolveAgentPin(tenantId, agentId,
|
||||
requestedRevision, true), requestedApprovalMode);
|
||||
requestedRevision, true), requestedApprovalMode, approvalSelectionSupported);
|
||||
if (!input.isEmpty() && WorkspaceToolProfiles.isPublicShell(pin.toolProfile())
|
||||
&& !workspaceShellEnabled) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
@@ -907,11 +921,13 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
: List.of("default", "auto-edit", "yolo");
|
||||
}
|
||||
|
||||
private static AgentPin selectApprovalMode(AgentPin pin, String requested) {
|
||||
private static AgentPin selectApprovalMode(AgentPin pin, String requested, boolean supported) {
|
||||
if (requested == null) {
|
||||
return pin;
|
||||
}
|
||||
if (!availableApprovalModes(pin).contains(requested)) {
|
||||
if (!availableApprovalModes(pin).contains(requested)
|
||||
&& !(supported && pin.digest() == null && "yolo".equals(requested)
|
||||
&& WorkspaceToolProfiles.isPublicShell(pin.toolProfile()))) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "unsupported_approval_mode",
|
||||
"The selected approval mode is not available for this Agent.");
|
||||
}
|
||||
@@ -1018,9 +1034,9 @@ 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) VALUES"
|
||||
+ " creator_actor_key, owner_actor_key, runtime_request_key, approval_mode) VALUES"
|
||||
+ " (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?,"
|
||||
+ " ?, ?, ?, ?, ?, ?, ?)",
|
||||
+ " ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tenantId, sessionId, agentId, pin.revision(), pin.digest(),
|
||||
title, now, now,
|
||||
workspace == null ? null : workspace.getWorkspaceId(),
|
||||
@@ -1035,7 +1051,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
? CsiFilesRetirementProfile.PROFILE
|
||||
: workspace == null ? null : pin.toolProfile(),
|
||||
actorKey, actorKey,
|
||||
runtimeRequestKey);
|
||||
runtimeRequestKey, workspace != null || pin.digest() != null ? pin.approvalMode() : "yolo");
|
||||
jdbc.update("INSERT INTO ma_event_stream"
|
||||
+ " (tenant_id, session_id, stream_kind, stream_id,"
|
||||
+ " covered_sequence, gmt_modified) VALUES"
|
||||
@@ -1052,15 +1068,6 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
ManagedReceipts.insertCreation(jdbc, tenantId, actorKey,
|
||||
idempotencyKey, requestDigest, sessionId, turnId, now);
|
||||
}
|
||||
// A definition pin applies to unbound Sessions too; qwen-code keeps
|
||||
// the column default unless bound.
|
||||
if (workspace != null || pin.digest() != null) {
|
||||
jdbc.update(
|
||||
"UPDATE ma_session SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", approval_mode = ? WHERE tenant_id = ? AND session_id = ?",
|
||||
pin.approvalMode(),
|
||||
tenantId,
|
||||
sessionId);
|
||||
}
|
||||
appendEvent(tenantId, sessionId, null, "session.created",
|
||||
Map.of("sessionId", sessionId), false, null, now);
|
||||
if (turnId != null) {
|
||||
@@ -3529,21 +3536,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
if (modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId))) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "unsupported_model", "The model is not published by this host");
|
||||
}
|
||||
WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId());
|
||||
requireSessionStatus(session.status(), "ACTIVE");
|
||||
requireCwdChangeRegistryFacts(session);
|
||||
if (hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "turn_active", "The Session has an active Turn or approval");
|
||||
}
|
||||
if (hasOpenOperation(tenantId, sessionId) || hasPendingRenameDelivery(tenantId, sessionId)
|
||||
|| hasRetainedRuntimeSession(tenantId, sessionId)
|
||||
|| !jdbc.queryForList("SELECT 1 FROM ma_turn WHERE tenant_id = ? AND session_id = ?"
|
||||
+ " AND status = 'RECOVERY_BLOCKED' LIMIT 1", tenantId, sessionId).isEmpty()
|
||||
|| !jdbc.queryForList("SELECT 1 FROM ma_session_head WHERE tenant_id = ?"
|
||||
+ " AND session_id = ? AND head_kind = 'MANAGED_SESSION' AND"
|
||||
+ " (state <> 'ACTIVE' OR recovery_status <> 'READY')", tenantId, sessionId).isEmpty()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "session_context_busy", "The Session requires settlement");
|
||||
}
|
||||
requireIdlePolicyChange(session);
|
||||
long current = jdbc.queryForObject("SELECT model_revision FROM ma_session WHERE tenant_id = ?"
|
||||
+ " AND session_id = ?", Long.class, tenantId, sessionId);
|
||||
if (current != expectedRevision || current >= 9_007_199_254_740_991L) {
|
||||
@@ -3572,6 +3565,154 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
return new WebShellModelChangeResult(sessionId, new WebShellModelSelection(modelId, revision), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, WebShellApprovalSelection> findApprovalSelections(String tenantId,
|
||||
List<String> sessionIds) {
|
||||
if (sessionIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId);
|
||||
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 ("
|
||||
+ 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());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> findApprovalOwners(String tenantId, String actorId, List<String> sessionIds) {
|
||||
if (sessionIds.isEmpty() || actorId == null || actorId.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId);
|
||||
args.add(ManagedWorkspaceRegistry.actorKey(tenantId, actorId));
|
||||
args.addAll(sessionIds);
|
||||
return new HashSet<>(jdbc.queryForList("SELECT session_id FROM ma_session WHERE tenant_id=?"
|
||||
+ " AND COALESCE(owner_actor_key, creator_actor_key)=? AND session_id IN ("
|
||||
+ placeholders(sessionIds.size()) + ")", String.class, args.toArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebShellApprovalSelection findTurnApprovalSelection(String tenantId, String sessionId,
|
||||
String promptId) {
|
||||
var rows = jdbc.query("SELECT t.approval_mode, t.approval_revision,"
|
||||
+ " 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"
|
||||
+ " WHERE t.tenant_id=? AND t.session_id=? AND t.prompt_id=?", (row, index) -> {
|
||||
if (!"qwen-code".equals(row.getString("agent_id"))
|
||||
|| row.getString("agent_definition_digest") != null || row.getString("workspace_id") == null
|
||||
|| !(WorkspaceToolProfiles.FILES.equals(row.getString("tool_profile"))
|
||||
|| WorkspaceToolProfiles.isPublicShell(row.getString("tool_profile")))) {
|
||||
return null;
|
||||
}
|
||||
String mode = row.getString("approval_mode");
|
||||
if (mode == null && row.getLong("selected_revision") != 0) {
|
||||
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"));
|
||||
}, tenantId, sessionId, promptId);
|
||||
if (rows.size() != 1) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "turn_not_found", "The Turn was not found");
|
||||
}
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId,
|
||||
String sessionId, String key, String digest, String mode, long expectedRevision,
|
||||
boolean supported) {
|
||||
WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId);
|
||||
SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId);
|
||||
if (session.workspace() == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "unsupported_feature",
|
||||
"Approval selection requires a Workspace Session");
|
||||
}
|
||||
WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId());
|
||||
if (!access.canRead()) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "session_not_found", "The Session was not found");
|
||||
}
|
||||
if (!workspaces.isSessionOwner(tenantId, actorId, sessionId)
|
||||
|| !access.atLeast(WorkspaceAccess.OPERATOR)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "session_operation_forbidden",
|
||||
"Only the Session owner with OPERATOR access may change approval policy");
|
||||
}
|
||||
String scope = com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys
|
||||
.key("SET_APPROVAL", sessionId, actorId);
|
||||
var namespace = com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.ReceiptNamespace.SESSION_COMMAND;
|
||||
var scopeKind = com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.ReceiptScope.ACTOR;
|
||||
ManagedReceipts.Receipt replay = ManagedReceipts.find(jdbc, namespace, tenantId,
|
||||
scopeKind, scope, key, false);
|
||||
if (replay != null) {
|
||||
if (!digest.equals(replay.requestDigest())
|
||||
|| !sessionId.equals(replay.sessionId())
|
||||
|| !"SET_APPROVAL".equals(replay.operation())
|
||||
|| !java.util.Arrays.equals(actorId.getBytes(StandardCharsets.UTF_8),
|
||||
replay.actorKey())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "idempotency_conflict",
|
||||
"The idempotency key was reused with different content");
|
||||
}
|
||||
Map<String, Object> result = readMap(replay.resultJson());
|
||||
return new WebShellApprovalChangeResult(sessionId, (String) result.get("mode"),
|
||||
((Number) result.get("revision")).longValue(), true);
|
||||
}
|
||||
if (!supported || !workspaceFilesEnabled || !WorkspaceToolProfiles.supportsApprovalSelection(session)
|
||||
|| WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !workspaceShellEnabled) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "unsupported_feature", "Approval selection is unavailable");
|
||||
}
|
||||
if (!WorkspaceToolProfiles.supportsApprovalMode(mode)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "unsupported_approval_mode", "The approval 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) {
|
||||
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 = ?,"
|
||||
+ " version = version + 1 WHERE tenant_id = ? AND session_id = ? AND approval_revision = ?",
|
||||
mode, revision, 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)),
|
||||
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);
|
||||
}
|
||||
|
||||
private void requireIdlePolicyChange(SessionRecord session) {
|
||||
String tenantId = session.tenantId();
|
||||
String sessionId = session.sessionId();
|
||||
WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId());
|
||||
requireSessionStatus(session.status(), "ACTIVE");
|
||||
requireCwdChangeRegistryFacts(session);
|
||||
if (hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "turn_active", "The Session has an active Turn or approval");
|
||||
}
|
||||
if (hasOpenOperation(tenantId, sessionId) || hasPendingRenameDelivery(tenantId, sessionId)
|
||||
|| hasRetainedRuntimeSession(tenantId, sessionId)
|
||||
|| !jdbc.queryForList("SELECT 1 FROM ma_turn WHERE tenant_id = ? AND session_id = ?"
|
||||
+ " AND status = 'RECOVERY_BLOCKED' LIMIT 1", tenantId, sessionId).isEmpty()
|
||||
|| !jdbc.queryForList("SELECT 1 FROM ma_session_head WHERE tenant_id = ?"
|
||||
+ " AND session_id = ? AND head_kind = 'MANAGED_SESSION' AND"
|
||||
+ " (state <> 'ACTIVE' OR recovery_status <> 'READY')", tenantId, sessionId).isEmpty()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "session_context_busy", "The Session requires settlement");
|
||||
}
|
||||
}
|
||||
|
||||
// Reads leave the Turn's input out; the public view never shows it.
|
||||
@Override
|
||||
public TurnPage listTurns(String tenantId, String sessionId,
|
||||
@@ -5313,12 +5454,21 @@ 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) SELECT"
|
||||
+ " status, gmt_create, gmt_modified, model_id, model_revision, approval_mode, approval_revision) SELECT"
|
||||
+ " ?, ?, ?, ?, ?, ?, 'ACCEPTED', ?, ?, model_id,"
|
||||
+ " CASE WHEN model_id IS NULL THEN NULL ELSE model_revision END"
|
||||
+ " CASE WHEN model_id IS NULL THEN NULL ELSE model_revision END,"
|
||||
+ " CASE WHEN agent_id=? AND agent_definition_digest IS NULL"
|
||||
+ " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1',"
|
||||
+ " 'hosted-workspace-shell/1','hosted-workspace-shell/2')"
|
||||
+ " THEN COALESCE(selected_approval_mode, approval_mode) ELSE NULL END,"
|
||||
+ " CASE WHEN agent_id=? AND agent_definition_digest IS 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"
|
||||
+ " FROM ma_session WHERE tenant_id = ? AND session_id = ?",
|
||||
tenantId, sessionId, turnId, promptId, writeJson(input),
|
||||
payloadDigest, now, now, tenantId, sessionId);
|
||||
payloadDigest, now, now, "qwen-code".getBytes(StandardCharsets.UTF_8),
|
||||
"qwen-code".getBytes(StandardCharsets.UTF_8), tenantId, sessionId);
|
||||
}
|
||||
|
||||
private void insertCommand(String tenantId, String operation,
|
||||
|
||||
+2
-1
@@ -558,7 +558,8 @@ public final class WorkspaceRecoveryStore {
|
||||
text(binding, key);
|
||||
}
|
||||
check(binding.path("workspaceGeneration").asLong() > 0 && binding.path("contextRevision").asLong() > 0, "source_drift");
|
||||
fields(source, row, "configRef", "workspace_config_ref", "policyRef", "workspace_policy_ref", "approvalMode", "approval_mode");
|
||||
fields(source, row, "configRef", "workspace_config_ref", "policyRef", "workspace_policy_ref", "approvalMode", "approval_mode",
|
||||
"selectedApprovalMode", "selected_approval_mode", "approvalRevision", "approval_revision");
|
||||
// The pinned definition identity (D8c-1); null for qwen-code.
|
||||
fields(source, row, "agentRevision", "agent_revision",
|
||||
"agentDefinitionDigest", "agent_definition_digest");
|
||||
|
||||
+10
@@ -37,6 +37,16 @@ public final class WorkspaceToolProfiles {
|
||||
return version == 1 || version == 2;
|
||||
}
|
||||
|
||||
public static boolean supportsApprovalMode(String mode) {
|
||||
return "default".equals(mode) || "auto-edit".equals(mode) || "yolo".equals(mode);
|
||||
}
|
||||
|
||||
public static boolean supportsApprovalSelection(StoreModels.SessionRecord session) {
|
||||
return session.workspace() != null && "qwen-code".equals(session.agentId())
|
||||
&& session.agentDefinitionDigest() == null
|
||||
&& (FILES.equals(session.toolProfile()) || isPublicShell(session.toolProfile()));
|
||||
}
|
||||
|
||||
public static boolean requiresApproval(String mode) {
|
||||
return "default".equals(mode) || "auto-edit".equals(mode);
|
||||
}
|
||||
|
||||
+2
-1
@@ -21,8 +21,9 @@ public final class ManagedSchema {
|
||||
"/db/managed-schema/V3__session_list_created_at.sql";
|
||||
public static final String WORKSPACE_CREATION = "/db/managed-schema/V4__workspace_creation.sql";
|
||||
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";
|
||||
// Additive migrations over the published V1 baseline, in version order.
|
||||
private static final List<String> EXTENSIONS = List.of(PRIVATE_AUTHORITY, SESSION_LIST, WORKSPACE_CREATION, MODEL_SELECTION);
|
||||
private static final List<String> EXTENSIONS = List.of(PRIVATE_AUTHORITY, SESSION_LIST, WORKSPACE_CREATION, MODEL_SELECTION, APPROVAL_SELECTION);
|
||||
private static final int CURRENT = EXTENSIONS.size() + 1;
|
||||
|
||||
private ManagedSchema() {}
|
||||
|
||||
+4
-2
@@ -119,7 +119,8 @@ public enum ManagedSchemaProjection {
|
||||
+ "root_session_id, parent_child_run_id, child_depth, agent_definition_digest, "
|
||||
+ "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"),
|
||||
+ "resource_gc_completed, runtime_request_key, csi_guard, model_id, model_revision, "
|
||||
+ "selected_approval_mode, approval_revision"),
|
||||
SESSION_HEAD(
|
||||
"tenant_id, workspace_id, session_id, storage_version, state, writer_generation, "
|
||||
+ "writer_id, writer_lease_until, lease_token_hash, journal_revision, "
|
||||
@@ -174,7 +175,8 @@ public enum ManagedSchemaProjection {
|
||||
+ "submission_attempted, harness_event_epoch, harness_last_event_id, dispatch_owner, "
|
||||
+ "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");
|
||||
+ "budget_exempt_failures, retry_after, model_id, model_revision, "
|
||||
+ "approval_mode, approval_revision");
|
||||
|
||||
private final String columns;
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE ma_session ADD COLUMN selected_approval_mode VARCHAR(32);
|
||||
ALTER TABLE ma_session ADD COLUMN approval_revision BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ma_session ADD CONSTRAINT ck_ma_session_approval_selection CHECK ((selected_approval_mode IS NULL AND approval_revision = 0) OR (selected_approval_mode IS NOT NULL AND approval_revision >= 1 AND approval_revision <= 9007199254740991 AND (CASE selected_approval_mode WHEN 'default' THEN 7 WHEN 'auto-edit' THEN 9 WHEN 'yolo' THEN 4 ELSE -1 END = CHAR_LENGTH(selected_approval_mode))));
|
||||
ALTER TABLE ma_turn ADD COLUMN approval_mode VARCHAR(32);
|
||||
ALTER TABLE ma_turn ADD COLUMN approval_revision BIGINT;
|
||||
ALTER TABLE ma_turn ADD CONSTRAINT ck_ma_turn_approval_selection CHECK ((approval_mode IS NULL AND approval_revision IS NULL) OR (approval_mode IS NOT NULL AND approval_revision IS NOT NULL AND approval_revision >= 0 AND approval_revision <= 9007199254740991 AND (CASE approval_mode WHEN 'default' THEN 7 WHEN 'auto-edit' THEN 9 WHEN 'yolo' THEN 4 ELSE -1 END = CHAR_LENGTH(approval_mode))));
|
||||
+235
-4
@@ -2,8 +2,8 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Qwen Managed Agent Public and WebShell API",
|
||||
"version": "1.50.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.51.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": [
|
||||
{
|
||||
@@ -3930,6 +3930,102 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/web-shell/v1/session-approval/query": {
|
||||
"post": {
|
||||
"tags": ["WebShell"],
|
||||
"operationId": "queryWebShellSessionApproval",
|
||||
"x-qwen-implementation-status": "implemented",
|
||||
"description": "Read the current policy and owner-authorized change support of a readable Workspace Session.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebShellSessionApprovalRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The current policy options.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebShellSessionApproval"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/web-shell/v1/session-approval/change": {
|
||||
"post": {
|
||||
"tags": ["WebShell"],
|
||||
"operationId": "changeWebShellSessionApproval",
|
||||
"x-qwen-implementation-status": "implemented",
|
||||
"description": "Change the next-Turn policy of an idle built-in Workspace Session. Requires its recorded owner and current OPERATOR access. Changes are revision-checked and actor/session-scoped idempotent.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebShellChangeApprovalRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The committed approval selection, or its original receipt on replay.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebShellApprovalChangeResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -5593,13 +5689,24 @@
|
||||
"approvalMode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"],
|
||||
"description": "Persisted Session approval policy; older servers may omit it. Existing Sessions cannot change policy through this API."
|
||||
"description": "Current effective approval policy; initialApprovalMode retains the immutable creation policy."
|
||||
},
|
||||
"modelContext": {
|
||||
"$ref": "#/components/schemas/WebShellModelContext"
|
||||
},
|
||||
"modelSelection": {
|
||||
"$ref": "#/components/schemas/WebShellModelSelection"
|
||||
},
|
||||
"approvalRevision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"initialApprovalMode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"],
|
||||
"description": "Immutable creation-time policy, used to confirm creation replay."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5668,12 +5775,16 @@
|
||||
},
|
||||
"foregroundShell": {
|
||||
"type": "boolean",
|
||||
"description": "Present only for persisted Shell Sessions, with no schema default; files and unbound Sessions omit it. True when Workspace files, Shell deployment admission and persisted default/auto-edit approval are valid. False prevents fresh sending. Disabling only the Shell flag preserves accepted cancellation and approval while Workspace files remain enabled and persisted approval remains valid; files-off or invalid approval can refuse attachment. This capability does not override authorization or recovery refusals.",
|
||||
"description": "Present only for persisted Shell Sessions, with no schema default; files and unbound Sessions omit it. True when Workspace files, Shell deployment admission and a valid persisted approval mode are valid. False prevents fresh sending. Disabling only the Shell flag preserves accepted cancellation and approval while Workspace files remain enabled and persisted approval remains valid; files-off or invalid approval can refuse attachment. This capability does not override authorization or recovery refusals.",
|
||||
"x-qwen-implementation-status": "implemented"
|
||||
},
|
||||
"modelChange": {
|
||||
"type": "boolean",
|
||||
"description": "The caller may request an idle Session model change using the server model catalog. Missing means unsupported; authorization and revision admission still apply."
|
||||
},
|
||||
"approvalModeChange": {
|
||||
"type": "boolean",
|
||||
"description": "The caller owns this built-in Workspace Session with OPERATOR access and the Harness supports approval-selection/1. Idle-state admission is checked separately. Missing means false."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10341,6 +10452,126 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebShellSessionApprovalRequest": {
|
||||
"type": "object",
|
||||
"required": ["sessionId"],
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebShellSessionApproval": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"approvalMode",
|
||||
"approvalRevision",
|
||||
"availableApprovalModes",
|
||||
"supported"
|
||||
],
|
||||
"properties": {
|
||||
"approvalMode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"]
|
||||
},
|
||||
"approvalRevision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"availableApprovalModes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"]
|
||||
}
|
||||
},
|
||||
"supported": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebShellChangeApprovalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sessionId",
|
||||
"approvalMode",
|
||||
"expectedRevision",
|
||||
"idempotencyKey"
|
||||
],
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"approvalMode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebShellApprovalChangeResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sessionId",
|
||||
"approvalMode",
|
||||
"approvalRevision",
|
||||
"replayed"
|
||||
],
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"approvalMode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"]
|
||||
},
|
||||
"approvalRevision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"replayed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebShellApprovalSelection": {
|
||||
"type": "object",
|
||||
"required": ["mode", "revision"],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["default", "auto-edit", "yolo"]
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ class BackfillWitnessesTest {
|
||||
"SELECT version FROM flyway_schema_history"
|
||||
+ " WHERE success = TRUE AND type = 'BASELINE'",
|
||||
String.class))
|
||||
.isEqualTo("5");
|
||||
.isEqualTo("6");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+18
-1
@@ -235,6 +235,11 @@ class ManagedAgentApiContractTest {
|
||||
List.of("WebShellModelContext")),
|
||||
entry(ApiModels.WebShellModelOption.class, List.of("WebShellModelOption")),
|
||||
entry(ApiModels.WebShellModelSelection.class, List.of("WebShellModelSelection")),
|
||||
entry(ApiModels.WebShellSessionApprovalRequest.class, List.of("WebShellSessionApprovalRequest")),
|
||||
entry(ApiModels.WebShellSessionApproval.class, List.of("WebShellSessionApproval")),
|
||||
entry(ApiModels.WebShellChangeApprovalRequest.class, List.of("WebShellChangeApprovalRequest")),
|
||||
entry(ApiModels.WebShellApprovalChangeResult.class, List.of("WebShellApprovalChangeResult")),
|
||||
entry(ApiModels.WebShellApprovalSelection.class, List.of("WebShellApprovalSelection")),
|
||||
entry(ApiModels.WebShellSessionModelsRequest.class, List.of("WebShellSessionModelsRequest")),
|
||||
entry(ApiModels.WebShellSessionModels.class, List.of("WebShellSessionModels")),
|
||||
entry(ApiModels.WebShellChangeModelRequest.class, List.of("WebShellChangeModelRequest")),
|
||||
@@ -1200,6 +1205,18 @@ class ManagedAgentApiContractTest {
|
||||
.isEqualTo(1);
|
||||
assertThat(webBound.at("/workspace/state").asText())
|
||||
.isEqualTo("ready");
|
||||
exchange(drift, "queryWebShellSessionApproval", 200,
|
||||
post(WEB_SHELL + "/session-approval/query").header(TENANT, workspaceTenant).principal(actor),
|
||||
"{\"sessionId\":\"" + webBoundId + "\"}");
|
||||
exchange(drift, "queryWebShellSessionApproval", 401,
|
||||
post(WEB_SHELL + "/session-approval/query").header(TENANT, workspaceTenant),
|
||||
"{\"sessionId\":\"" + webBoundId + "\"}");
|
||||
exchange(drift, "changeWebShellSessionApproval", 409,
|
||||
post(WEB_SHELL + "/session-approval/change").header(TENANT, workspaceTenant).principal(actor),
|
||||
"{\"sessionId\":\"" + webBoundId + "\",\"approvalMode\":\"yolo\",\"expectedRevision\":0,\"idempotencyKey\":\"approval-key\"}");
|
||||
exchange(drift, "changeWebShellSessionApproval", 401,
|
||||
post(WEB_SHELL + "/session-approval/change").header(TENANT, workspaceTenant),
|
||||
"{\"sessionId\":\"" + webBoundId + "\",\"approvalMode\":\"yolo\",\"expectedRevision\":0,\"idempotencyKey\":\"approval-key\"}");
|
||||
exchange(drift, "queryWebShellSessionModels", 200,
|
||||
post(WEB_SHELL + "/session-models/query").header(TENANT, workspaceTenant).principal(actor),
|
||||
"{\"sessionId\":\"" + webBoundId + "\"}");
|
||||
@@ -2388,7 +2405,7 @@ class ManagedAgentApiContractTest {
|
||||
assertThat(other.get("capabilities"))
|
||||
.isEqualTo(json("""
|
||||
{"tasks":true,"artifacts":false,"actions":false,"workspaceTurns":false,"sessionClose":true,
|
||||
"sessionArchive":true,"sessionUnarchive":true,"sessionDelete":true,"cwdChange":false,"modelChange":false}
|
||||
"sessionArchive":true,"sessionUnarchive":true,"sessionDelete":true,"cwdChange":false,"modelChange":false,"approvalModeChange":false}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=${mysql.url}",
|
||||
"spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
|
||||
"spring.datasource.username=${mysql.user}",
|
||||
"spring.datasource.password=${mysql.password:}",
|
||||
"qwen.managed-agent.harness.enabled=false"
|
||||
})
|
||||
class ManagedApprovalChangeMySqlIT extends ManagedApprovalChangeTest {
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalChangeResult;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalSelection;
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.service.HarnessCoordinator;
|
||||
import com.alibaba.qwen.code.managedagent.service.ManagedAgentService;
|
||||
import com.alibaba.qwen.code.managedagent.service.RequestDigests;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:approval-change;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa", "spring.datasource.password=",
|
||||
"qwen.managed-agent.harness.enabled=false"
|
||||
})
|
||||
class ManagedApprovalChangeTest {
|
||||
@Autowired private JdbcTemplate jdbc;
|
||||
@Autowired private ObjectMapper json;
|
||||
@Autowired private ManagedWorkspaceRegistry registry;
|
||||
@Autowired private PlatformTransactionManager manager;
|
||||
private String tenant;
|
||||
private String session;
|
||||
private ManagedAgentStore store;
|
||||
private ManagedAgentService service;
|
||||
private HarnessConnector harness;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
tenant = "approval-change-" + UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO ma_workspace (tenant_id,workspace_id,workspace_generation,storage_id,"
|
||||
+ "display_name,config_ref,policy_ref,state) VALUES (?,'ws',1,'storage','Workspace',?,?,'ACTIVE')",
|
||||
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant("actor", "OWNER");
|
||||
store = store();
|
||||
harness = mock(HarnessConnector.class);
|
||||
when(harness.supportsApprovalSelection()).thenReturn(true);
|
||||
when(harness.isWorkspaceFilesAvailable()).thenReturn(true);
|
||||
service = service(store);
|
||||
session = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "create",
|
||||
"qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."))).sessionId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesExplicitFullAccessAndPreservesTheInitialPolicyAcrossRoundTrips() {
|
||||
assertThat(service.queryWebShellSessionOptions(tenant, "actor", "qwen-code").availableApprovalModes())
|
||||
.containsExactly("default", "auto-edit", "yolo");
|
||||
assertThat(service.queryWebShellSessionApproval(tenant, "actor", session).supported()).isTrue();
|
||||
var first = change("actor", "first", "yolo", 0);
|
||||
assertThat(first.approvalRevision()).isEqualTo(1);
|
||||
var summary = service.getWebShellSession(tenant, "actor", session);
|
||||
assertThat(summary.approvalMode()).isEqualTo("yolo");
|
||||
assertThat(summary.initialApprovalMode()).isEqualTo("default");
|
||||
assertThat(summary.approvalRevision()).isEqualTo(1);
|
||||
assertThat(summary.capabilities().actions()).isTrue();
|
||||
assertThat(summary.capabilities().foregroundShell()).isTrue();
|
||||
change("actor", "second", "auto-edit", 1);
|
||||
change("actor", "third", "default", 2);
|
||||
assertThat(store.requireSession(tenant, session).approvalMode()).isEqualTo("default");
|
||||
assertThat(service.listWebShellSessions(tenant, "actor", null, 100).data()).singleElement()
|
||||
.satisfies(row -> assertThat(row.approvalRevision()).isEqualTo(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitlyCreatesFullAccessAndCanReturnToAskingWithoutChangingInitialPolicy() {
|
||||
var created = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "full-access",
|
||||
"qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."), "yolo"));
|
||||
assertThat(store.requireSession(tenant, created.sessionId()).approvalMode()).isEqualTo("yolo");
|
||||
transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", created.sessionId(),
|
||||
"ask", "default", 0));
|
||||
var summary = service.getWebShellSession(tenant, "actor", created.sessionId());
|
||||
assertThat(summary.approvalMode()).isEqualTo("default");
|
||||
assertThat(summary.initialApprovalMode()).isEqualTo("yolo");
|
||||
assertThat(summary.capabilities().actions()).isTrue();
|
||||
when(harness.supportsApprovalSelection()).thenReturn(false);
|
||||
assertCode(() -> transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "old-host",
|
||||
"qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."), "yolo")), "unsupported_approval_mode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void receiptsDoNotChangeTheSelectionAgainAndRequireCurrentOwnerAndOperator() {
|
||||
var first = change("actor", "first", "yolo", 0);
|
||||
change("actor", "second", "auto-edit", 1);
|
||||
when(harness.supportsApprovalSelection()).thenReturn(false);
|
||||
assertThat(change("actor", "first", "yolo", 0))
|
||||
.isEqualTo(new WebShellApprovalChangeResult(session, "yolo", first.approvalRevision(), true));
|
||||
assertThat(service.getWebShellSession(tenant, "actor", session).approvalMode()).isEqualTo("auto-edit");
|
||||
assertCode(() -> change("actor", "first", "default", 0), "idempotency_conflict");
|
||||
grant("operator", "OPERATOR");
|
||||
assertCode(() -> change("operator", "other", "yolo", 2), "session_operation_forbidden");
|
||||
grant("actor", "READER");
|
||||
assertCode(() -> change("actor", "first", "yolo", 0), "session_operation_forbidden");
|
||||
jdbc.update("DELETE FROM ma_workspace_access WHERE tenant_id=? AND actor_id=?", tenant, "actor");
|
||||
assertCode(() -> change("actor", "first", "yolo", 0), "session_not_found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsBothTheFirstAndLaterTurnsAndNeverRewritesOldSnapshots() {
|
||||
var input = List.<Map<String, Object>>of(Map.of("type", "text", "text", "hello"));
|
||||
var initial = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "with-input",
|
||||
"qwen-code", null, null, null, List.of(new ApiModels.InputBlock("input_text", "hello")),
|
||||
new WorkspaceSelection("ws", "."), "auto-edit"));
|
||||
String firstPrompt = store.findTurn(tenant, initial.sessionId(), initial.turnId()).orElseThrow().promptId();
|
||||
assertThat(store.findTurnApprovalSelection(tenant, initial.sessionId(), firstPrompt))
|
||||
.isEqualTo(new WebShellApprovalSelection("auto-edit", 0));
|
||||
change("actor", "yolo", "yolo", 0);
|
||||
var turn = transaction().execute(status -> store.insertTurnCommand(tenant, "SUBMIT_TURN", "turn-a", "digest-a",
|
||||
session, input, SubmitHarnessTurn.computePayloadDigest(input)));
|
||||
String prompt = store.findTurn(tenant, session, turn.turnId()).orElseThrow().promptId();
|
||||
assertThat(store.findTurnApprovalSelection(tenant, session, prompt)).isEqualTo(new WebShellApprovalSelection("yolo", 1));
|
||||
assertCode(() -> change("actor", "busy", "default", 1), "turn_active");
|
||||
jdbc.update("UPDATE ma_turn SET status='COMPLETED' WHERE tenant_id=? AND turn_id=?", tenant, turn.turnId());
|
||||
change("actor", "auto", "auto-edit", 1);
|
||||
assertThat(store.findTurnApprovalSelection(tenant, session, prompt)).isEqualTo(new WebShellApprovalSelection("yolo", 1));
|
||||
assertThat(transaction().execute(status -> store.insertTurnCommand(tenant, "SUBMIT_TURN", "turn-a", "digest-a",
|
||||
session, input, SubmitHarnessTurn.computePayloadDigest(input))).replayed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsStoredDefinitionsFixedAndRejectsUnknownModes() {
|
||||
assertCode(() -> change("actor", "invalid", "auto", 0), "unsupported_approval_mode");
|
||||
jdbc.update("UPDATE ma_session SET agent_definition_digest=? WHERE tenant_id=? AND session_id=?", "a".repeat(64), tenant, session);
|
||||
assertCode(() -> change("actor", "custom", "yolo", 0), "unsupported_feature");
|
||||
assertThat(service.getWebShellSession(tenant, "actor", session).capabilities().approvalModeChange()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackForLegacyTurnsOnlyBeforeAnyPolicyOverride() {
|
||||
var input = List.<Map<String, Object>>of(Map.of("type", "text", "text", "hello"));
|
||||
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'"
|
||||
+ " WHERE tenant_id=? AND turn_id=?", tenant, admitted.turnId());
|
||||
assertThat(store.findTurnApprovalSelection(tenant, session, turn.promptId()))
|
||||
.isNull();
|
||||
assertThat(store.requireSession(tenant, session).approvalMode()).isEqualTo("default");
|
||||
change("actor", "selected", "yolo", 0);
|
||||
assertCode(() -> store.findTurnApprovalSelection(tenant, session, turn.promptId()), "approval_snapshot_missing");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void serializesTurnAdmissionAndPolicyChangeUnderTheSessionLock(boolean changeFirst) throws Exception {
|
||||
var input = List.<Map<String, Object>>of(Map.of("type", "text", "text", "hello"));
|
||||
CountDownLatch held = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||
var first = executor.submit(() -> transaction().execute(status -> {
|
||||
String result;
|
||||
if (changeFirst) {
|
||||
service.changeWebShellSessionApproval(tenant, "actor", session, "race", "yolo", 0);
|
||||
result = "changed";
|
||||
} else {
|
||||
result = store.insertTurnCommand(tenant, "SUBMIT_TURN", "race-turn", "race-digest",
|
||||
session, input, SubmitHarnessTurn.computePayloadDigest(input)).turnId();
|
||||
}
|
||||
held.countDown();
|
||||
try {
|
||||
if (!release.await(10, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("The test did not release the Session transaction");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interrupted);
|
||||
}
|
||||
return result;
|
||||
}));
|
||||
try {
|
||||
assertThat(held.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
var second = executor.submit(() -> {
|
||||
if (changeFirst) {
|
||||
return transaction().execute(status -> store.insertTurnCommand(tenant, "SUBMIT_TURN", "race-turn", "race-digest",
|
||||
session, input, SubmitHarnessTurn.computePayloadDigest(input)).turnId());
|
||||
}
|
||||
try {
|
||||
change("actor", "race", "yolo", 0);
|
||||
return "unexpected-change";
|
||||
} catch (ApiException conflict) {
|
||||
return conflict.getCode();
|
||||
}
|
||||
});
|
||||
release.countDown();
|
||||
String initial = first.get(10, TimeUnit.SECONDS);
|
||||
String after = second.get(10, TimeUnit.SECONDS);
|
||||
if (!changeFirst) {
|
||||
assertThat(after).isEqualTo("turn_active");
|
||||
}
|
||||
var turn = store.findTurn(tenant, session, changeFirst ? after : initial).orElseThrow();
|
||||
assertThat(store.findTurnApprovalSelection(tenant, session, turn.promptId()))
|
||||
.isEqualTo(new WebShellApprovalSelection(changeFirst ? "yolo" : "default", changeFirst ? 1 : 0));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentChangesCannotBothWinTheSameRevision() throws Exception {
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||
Callable<String> call = () -> {
|
||||
start.await();
|
||||
try {
|
||||
return Long.toString(change("actor", UUID.randomUUID().toString(), "yolo", 0).approvalRevision());
|
||||
} catch (ApiException conflict) {
|
||||
return conflict.getCode();
|
||||
}
|
||||
};
|
||||
var first = executor.submit(call);
|
||||
var second = executor.submit(call);
|
||||
start.countDown();
|
||||
assertThat(List.of(first.get(), second.get())).containsExactlyInAnyOrder("1", "approval_selection_conflict");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"reader", "stranger", "protocol", "revision", "approval", "recovery", "turn-recovery", "cwd", "closed"})
|
||||
void refusesUnpublishedUnauthorizedOrBusyChangesWithoutAReceipt(String shape) {
|
||||
grant("reader", "READER");
|
||||
String actor = shape.equals("reader") ? "reader" : shape.equals("stranger") ? "stranger" : "actor";
|
||||
if (shape.equals("protocol")) {
|
||||
when(harness.supportsApprovalSelection()).thenReturn(false);
|
||||
}
|
||||
if (shape.equals("approval")) {
|
||||
jdbc.update("INSERT INTO ma_session_record (session_scope_key,record_key,tenant_id,session_id,"
|
||||
+ "domain,record_id,revision,gmt_create,record_kind,state,action_id,action_version,options_json)"
|
||||
+ " VALUES (?,?,?,?,'action','action',1,1,'ACTION','requested','action',1,?)",
|
||||
ManagedSessionStore.sessionScopeKey(tenant, session), "a".repeat(64), tenant, session,
|
||||
"{\"expiresAt\":" + (System.currentTimeMillis() + 100_000) + "}");
|
||||
}
|
||||
if (shape.equals("recovery")) {
|
||||
transaction().execute(status -> new ManagedSessionStore(jdbc).acquireWriter(tenant, session,
|
||||
"model-selection-writer-token-0123456789", new AcquireWriterRequest("ws", "writer", 60_000L)));
|
||||
jdbc.update("UPDATE ma_session_head SET recovery_status='BLOCKED' WHERE tenant_id=?", tenant);
|
||||
}
|
||||
if (shape.equals("turn-recovery")) {
|
||||
var input = List.<Map<String, Object>>of(Map.of("type", "text", "text", "hello"));
|
||||
transaction().execute(status -> store.insertTurnCommand(tenant, "SUBMIT_TURN", "parked", "digest",
|
||||
session, input, SubmitHarnessTurn.computePayloadDigest(input)));
|
||||
jdbc.update("UPDATE ma_turn SET status='RECOVERY_BLOCKED' WHERE tenant_id=?", tenant);
|
||||
}
|
||||
if (shape.equals("cwd")) {
|
||||
transaction().execute(status -> store.beginCwdChangeOperation(tenant, session, "actor", "actor-digest",
|
||||
"cwd", "digest-cwd", "sub", 1));
|
||||
}
|
||||
if (shape.equals("closed")) {
|
||||
jdbc.update("UPDATE ma_session SET status='CLOSED' WHERE tenant_id=?", tenant);
|
||||
}
|
||||
String expected = switch (shape) {
|
||||
case "reader" -> "session_operation_forbidden";
|
||||
case "stranger" -> "session_not_found";
|
||||
case "protocol" -> "unsupported_feature";
|
||||
case "revision" -> "approval_selection_conflict";
|
||||
case "approval" -> "turn_active";
|
||||
case "closed" -> "session_state_conflict";
|
||||
default -> "session_context_busy";
|
||||
};
|
||||
assertCode(() -> change(actor, "refused", "yolo",
|
||||
shape.equals("revision") ? 1 : 0), expected);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_request_receipt WHERE tenant_id=? AND operation='SET_APPROVAL'",
|
||||
Integer.class, tenant)).isZero();
|
||||
assertThat(store.findApprovalSelections(tenant, List.of(session)).get(session))
|
||||
.isEqualTo(new WebShellApprovalSelection("default", 0));
|
||||
}
|
||||
|
||||
private ManagedAgentStore store() {
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(true);
|
||||
properties.getHarness().setApprovalMode("default");
|
||||
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
|
||||
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage", "/tmp")));
|
||||
return new ManagedAgentStore(jdbc, json, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
}
|
||||
|
||||
private ManagedAgentService service(ManagedAgentStore source) {
|
||||
return new ManagedAgentService(source, new RequestDigests(), mock(HarnessCoordinator.class), harness, registry);
|
||||
}
|
||||
|
||||
private WebShellApprovalChangeResult change(String actor, String key, String mode, long revision) {
|
||||
return transaction().execute(status -> service.changeWebShellSessionApproval(tenant, actor, session, key, mode, revision));
|
||||
}
|
||||
|
||||
private void grant(String actor, String role) {
|
||||
jdbc.update("DELETE FROM ma_workspace_access WHERE tenant_id=? AND workspace_id=? AND actor_id=?", tenant, "ws", actor);
|
||||
jdbc.update("INSERT INTO ma_workspace_access (tenant_id,workspace_id,actor_id,role) VALUES (?,?,?,?)",
|
||||
tenant, "ws", actor.getBytes(java.nio.charset.StandardCharsets.UTF_8), role);
|
||||
}
|
||||
|
||||
private TransactionTemplate transaction() { return new TransactionTemplate(manager); }
|
||||
|
||||
private void assertCode(Runnable action, String code) {
|
||||
assertThatThrownBy(action::run).isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo(code));
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -451,8 +451,8 @@ class SurfaceAdmissionAcceptanceTest {
|
||||
// An anonymous mutation needs an actor: the automation
|
||||
// mutations name it up front (401), while the lifecycle
|
||||
// family stays invisible (404).
|
||||
expect(entry, null, automationEntry(entry) ? 401 : 404,
|
||||
automationEntry(entry) ? "actor_required"
|
||||
expect(entry, null, (automationEntry(entry) || entry.capabilities().contains(Capability.SESSION_APPROVAL_CHANGE)) ? 401 : 404,
|
||||
(automationEntry(entry) || entry.capabilities().contains(Capability.SESSION_APPROVAL_CHANGE)) ? "actor_required"
|
||||
: "session_not_found");
|
||||
}
|
||||
case WORKSPACE_ALLOCATION -> {
|
||||
@@ -1534,7 +1534,9 @@ class SurfaceAdmissionAcceptanceTest {
|
||||
case ARTIFACT_LIST -> "{\"sessionId\":\"" + session + "\"}";
|
||||
case WORKSPACE_CREATE -> "{\"displayName\":\"New workspace\",\"idempotencyKey\":\"create-key\"}";
|
||||
case SESSION_OPTIONS -> "{\"agentId\":\"qwen-code\"}";
|
||||
case SESSION_MODELS -> "{\"sessionId\":\"" + session + "\"}";
|
||||
case SESSION_APPROVAL, SESSION_MODELS -> "{\"sessionId\":\"" + session + "\"}";
|
||||
case SESSION_APPROVAL_CHANGE -> "{\"sessionId\":\"" + session
|
||||
+ "\",\"idempotencyKey\":\"approval-change\",\"approvalMode\":\"yolo\",\"expectedRevision\":0}";
|
||||
case SESSION_MODEL_CHANGE -> "{\"sessionId\":\"" + session
|
||||
+ "\",\"idempotencyKey\":\"model-change\",\"modelId\":\"model-a\",\"expectedRevision\":0}";
|
||||
case WORKSPACE_LIST -> "{}";
|
||||
|
||||
+10
@@ -293,6 +293,14 @@ public enum SurfaceRegistry {
|
||||
"/api/agent/web-shell/v1/session-models/change",
|
||||
Surface.WEBSHELL, RuleClass.OPERATOR,
|
||||
EnumSet.of(Capability.SESSION_MODEL_CHANGE)),
|
||||
WEBSHELL_SESSION_APPROVAL(Method.POST,
|
||||
"/api/agent/web-shell/v1/session-approval/query",
|
||||
Surface.WEBSHELL, RuleClass.READER_ACTOR,
|
||||
EnumSet.of(Capability.SESSION_APPROVAL)),
|
||||
WEBSHELL_SESSION_APPROVAL_CHANGE(Method.POST,
|
||||
"/api/agent/web-shell/v1/session-approval/change",
|
||||
Surface.WEBSHELL, RuleClass.OWNER,
|
||||
EnumSet.of(Capability.SESSION_APPROVAL_CHANGE)),
|
||||
// ManagedSessionStoreController: internal writer routes.
|
||||
INTERNAL_EXECUTION_AUTHORIZE(Method.POST,
|
||||
"/internal/managed-session-store/v1/sessions/{sessionId}"
|
||||
@@ -535,6 +543,8 @@ public enum SurfaceRegistry {
|
||||
ARTIFACT_CONTENT,
|
||||
WORKSPACE_CREATE,
|
||||
SESSION_OPTIONS,
|
||||
SESSION_APPROVAL,
|
||||
SESSION_APPROVAL_CHANGE,
|
||||
SESSION_MODELS,
|
||||
SESSION_MODEL_CHANGE,
|
||||
WORKSPACE_LIST,
|
||||
|
||||
+17
-10
@@ -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}) {
|
||||
for (int version : new int[] {1, 2, 3, 4, 5}) {
|
||||
var source = source();
|
||||
Flyway.configure()
|
||||
.dataSource(source)
|
||||
@@ -51,7 +51,8 @@ class ManagedSchemaConfigurationTest {
|
||||
var session = jdbc.queryForMap("SELECT "
|
||||
+ ManagedSchemaProjection.SESSION.columns().replace(
|
||||
", runtime_request_key, csi_guard", "")
|
||||
.replace(", model_id, model_revision", "") + " FROM ma_session");
|
||||
.replace(", model_id, model_revision", "")
|
||||
.replace(", selected_approval_mode, approval_revision", "") + " FROM ma_session");
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(version);
|
||||
assertThatThrownBy(() -> ManagedSchema.initialize(source))
|
||||
.hasRootCauseMessage(
|
||||
@@ -65,7 +66,7 @@ class ManagedSchemaConfigurationTest {
|
||||
.locations(ManagedSchema.FLYWAY_LOCATION)
|
||||
.load();
|
||||
new ManagedSchemaConfiguration().freshManagedSchema(source).migrate(current);
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(5);
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(6);
|
||||
assertThat(sessionListColumns(source)).containsExactly("tenant_id", "gmt_create", "session_id");
|
||||
assertThat(
|
||||
jdbc.queryForList(
|
||||
@@ -78,7 +79,9 @@ class ManagedSchemaConfigurationTest {
|
||||
.containsEntry("csi_guard", false)
|
||||
.containsEntry("runtime_request_key", null)
|
||||
.containsEntry("model_id", null)
|
||||
.containsEntry("model_revision", 0L);
|
||||
.containsEntry("model_revision", 0L)
|
||||
.containsEntry("selected_approval_mode", null)
|
||||
.containsEntry("approval_revision", 0L);
|
||||
assertThat(
|
||||
jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM \"flyway_schema_history\" WHERE"
|
||||
@@ -92,9 +95,9 @@ class ManagedSchemaConfigurationTest {
|
||||
|
||||
@Test
|
||||
void baselinesTheActualUnversionedCanonicalCatalog() throws Exception {
|
||||
for (int version : new int[] {1, 2, 3, 4, 5}) {
|
||||
for (int version : new int[] {1, 2, 3, 4, 5, 6}) {
|
||||
var source = source();
|
||||
if (version < 5) {
|
||||
if (version < 6) {
|
||||
executeResource(source, ManagedSchema.RESOURCE);
|
||||
if (version >= 2) {
|
||||
executeResource(source, ManagedSchema.PRIVATE_AUTHORITY);
|
||||
@@ -105,6 +108,9 @@ class ManagedSchemaConfigurationTest {
|
||||
if (version >= 4) {
|
||||
executeResource(source, ManagedSchema.WORKSPACE_CREATION);
|
||||
}
|
||||
if (version >= 5) {
|
||||
executeResource(source, ManagedSchema.MODEL_SELECTION);
|
||||
}
|
||||
} else {
|
||||
ManagedSchema.initialize(source);
|
||||
}
|
||||
@@ -123,7 +129,7 @@ class ManagedSchemaConfigurationTest {
|
||||
+ " \"type\" = 'BASELINE'",
|
||||
String.class))
|
||||
.isEqualTo(Integer.toString(version));
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(5);
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(6);
|
||||
assertThat(sessionListColumns(source)).containsExactly("tenant_id", "gmt_create", "session_id");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session", Integer.class))
|
||||
.isEqualTo(1);
|
||||
@@ -169,7 +175,8 @@ class ManagedSchemaConfigurationTest {
|
||||
var session = jdbc.queryForMap("SELECT "
|
||||
+ ManagedSchemaProjection.SESSION.columns().replace(
|
||||
", runtime_request_key, csi_guard", "")
|
||||
.replace(", model_id, model_revision", "") + " FROM ma_session");
|
||||
.replace(", model_id, model_revision", "")
|
||||
.replace(", selected_approval_mode, approval_revision", "") + " FROM ma_session");
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(2);
|
||||
assertThat(sessionListColumns(source))
|
||||
.containsExactly("tenant_id", "gmt_modified", "session_id");
|
||||
@@ -185,7 +192,7 @@ class ManagedSchemaConfigurationTest {
|
||||
.locations(ManagedSchema.FLYWAY_LOCATION)
|
||||
.load());
|
||||
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(5);
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(6);
|
||||
assertThat(sessionListColumns(source))
|
||||
.containsExactly("tenant_id", "gmt_create", "session_id");
|
||||
assertThat(
|
||||
@@ -231,7 +238,7 @@ class ManagedSchemaConfigurationTest {
|
||||
|
||||
ManagedSchema.initialize(source);
|
||||
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(5);
|
||||
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(6);
|
||||
assertThat(sessionListColumns(source))
|
||||
.containsExactly("tenant_id", "gmt_create", "session_id");
|
||||
}
|
||||
|
||||
+132
-14
@@ -13,6 +13,7 @@ import com.alibaba.qwen.code.daemon.MutationOutcomeUnknownException;
|
||||
import com.alibaba.qwen.code.daemon.PromptReceipt;
|
||||
import com.alibaba.qwen.code.daemon.SessionCreationOutcomeUnknownException;
|
||||
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellApprovalSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
|
||||
@@ -22,6 +23,8 @@ import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.ToolRe
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnSummary;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.WriterCredentialPolicy;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException;
|
||||
@@ -42,6 +45,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -79,6 +83,120 @@ class QwenHostedHarnessConnectorTest {
|
||||
private static final String SUBMIT_DIGEST =
|
||||
SubmitHarnessTurn.computePayloadDigest(SUBMIT_CONTENT);
|
||||
|
||||
@Test
|
||||
void keepsRevisionZeroWireIdentityAcrossCapabilityUpgradeAndForwardsPositiveSnapshots() {
|
||||
var client = mock(HostedHarnessClient.class);
|
||||
var capabilities = mock(HostedHarnessCapabilities.class);
|
||||
var attached = mock(HarnessSessionRef.class);
|
||||
when(client.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.getBootId()).thenReturn(BOOT_ID);
|
||||
when(capabilities.getFeatures()).thenReturn(List.of());
|
||||
when(attached.getHarnessBootId()).thenReturn(BOOT_ID);
|
||||
when(attached.getApprovalMode()).thenReturn("default");
|
||||
when(client.loadSession(any())).thenReturn(attached);
|
||||
when(client.submitTurn(any())).thenReturn(mock(PromptReceipt.class));
|
||||
var sessions = mock(AgentStateStore.class);
|
||||
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(new SessionRecord("tenant-a", SESSION_ID,
|
||||
"qwen-code", null, null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "selected-workspace", 1, "storage", ".",
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "default", "hosted-workspace-files/1"));
|
||||
when(sessions.findTurnApprovalSelection("tenant-a", SESSION_ID, SUBMIT_PROMPT_ID))
|
||||
.thenReturn(new WebShellApprovalSelection("default", 0));
|
||||
var actions = mock(ManagedActionStore.class);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
|
||||
var settings = properties();
|
||||
settings.getHarness().setWorkspaceFilesEnabled(true);
|
||||
var connector = new QwenHostedHarnessConnector(settings, sessions, mock(WorkspaceExecutionStore.class), actions);
|
||||
ReflectionTestUtils.setField(connector, "client", client);
|
||||
connector.submit("tenant-a", SESSION_ID, SUBMIT_PROMPT_ID, SUBMIT_CONTENT, SUBMIT_DIGEST);
|
||||
when(capabilities.getFeatures()).thenReturn(List.of("approval-selection/1"));
|
||||
connector.submit("tenant-a", SESSION_ID, SUBMIT_PROMPT_ID, SUBMIT_CONTENT, SUBMIT_DIGEST);
|
||||
String selectedPrompt = "77777777-7777-4777-8777-777777777777";
|
||||
when(sessions.findTurnApprovalSelection("tenant-a", SESSION_ID, selectedPrompt))
|
||||
.thenReturn(new WebShellApprovalSelection("yolo", 1));
|
||||
connector.submit("tenant-a", SESSION_ID, selectedPrompt, SUBMIT_CONTENT, SUBMIT_DIGEST);
|
||||
var sent = ArgumentCaptor.forClass(SubmitHarnessTurn.class);
|
||||
verify(client, times(3)).submitTurn(sent.capture());
|
||||
for (int index = 0; index < 2; index++) {
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(sent.getAllValues().get(index), "toJson"))
|
||||
.doesNotContainKey("approvalSelection");
|
||||
}
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(sent.getAllValues().get(2), "toJson"))
|
||||
.containsEntry("approvalSelection", Map.of("mode", "yolo", "revision", 1L));
|
||||
when(capabilities.getFeatures()).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> connector.submit("tenant-a", SESSION_ID, selectedPrompt, SUBMIT_CONTENT, SUBMIT_DIGEST))
|
||||
.hasMessageContaining("approval selection is unavailable");
|
||||
verify(client, times(3)).submitTurn(any());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"default,1", "yolo,0"})
|
||||
void refusesAnOldHarnessBeforeAnyExecutionCapableAttachmentButKeepsCancellation(String initial, long revision) {
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
|
||||
HarnessSessionRef attached = mock(HarnessSessionRef.class);
|
||||
when(client.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.getBootId()).thenReturn(BOOT_ID);
|
||||
when(capabilities.getFeatures()).thenReturn(List.of("approval-selection/1"));
|
||||
when(attached.getHarnessBootId()).thenReturn(BOOT_ID);
|
||||
when(attached.getApprovalMode()).thenReturn(initial);
|
||||
when(client.createSession(any())).thenReturn(attached);
|
||||
when(client.loadSession(any())).thenReturn(attached);
|
||||
var sessions = mock(AgentStateStore.class);
|
||||
var session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null,
|
||||
null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "selected-workspace", 1, "storage", "child",
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), initial, "hosted-workspace-shell/1");
|
||||
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
|
||||
when(sessions.findApprovalSelections("tenant-a", List.of(SESSION_ID)))
|
||||
.thenReturn(Map.of(SESSION_ID, new WebShellApprovalSelection(initial, revision)));
|
||||
var execution = mock(WorkspaceExecutionStore.class);
|
||||
var properties = properties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
var actions = mock(ManagedActionStore.class);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn(initial);
|
||||
var connector = new QwenHostedHarnessConnector(properties, sessions, execution, actions);
|
||||
ReflectionTestUtils.setField(connector, "client", client);
|
||||
connector.createOrLoad("tenant-a", SESSION_ID, false);
|
||||
clearInvocations(client);
|
||||
when(capabilities.getFeatures()).thenReturn(List.of());
|
||||
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, false))
|
||||
.hasMessageContaining("approval selection is unavailable");
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, true, true))
|
||||
.hasMessageContaining("approval selection is unavailable");
|
||||
assertThatThrownBy(() -> connector.continueManagedRuntime("tenant-a", SESSION_ID, SUBMIT_PROMPT_ID, "checkpoint", "activation"))
|
||||
.hasMessageContaining("approval selection is unavailable");
|
||||
assertThatThrownBy(() -> connector.recoverManagedRuntime("tenant-a", SESSION_ID, false))
|
||||
.hasMessageContaining("approval selection is unavailable");
|
||||
verify(client, never()).loadSession(any());
|
||||
verify(client, never()).createSession(any());
|
||||
verify(client, never()).continueManagedRuntime(any(), any(), any(), any());
|
||||
|
||||
connector.recoverManagedCancellation("tenant-a", SESSION_ID);
|
||||
var load = ArgumentCaptor.forClass(LoadHarnessSession.class);
|
||||
verify(client).loadSession(load.capture());
|
||||
Map<String, Object> request = ReflectionTestUtils.invokeMethod(load.getValue(), "toJson");
|
||||
assertThat(request).containsEntry("passiveManagedRuntimeRecovery", true)
|
||||
.containsEntry("cancellationTakeover", true)
|
||||
.doesNotContainKey("driveRuntimeRecovery");
|
||||
if (revision > 0) {
|
||||
clearInvocations(client);
|
||||
when(capabilities.getFeatures()).thenReturn(List.of("approval-selection/1"));
|
||||
var active = mock(TurnSummary.class);
|
||||
when(active.turnId()).thenReturn("active-turn");
|
||||
when(sessions.findActiveTurns("tenant-a", List.of(SESSION_ID))).thenReturn(Map.of(SESSION_ID, active));
|
||||
var turn = mock(TurnRecord.class);
|
||||
when(turn.promptId()).thenReturn(SUBMIT_PROMPT_ID);
|
||||
when(sessions.findTurn("tenant-a", SESSION_ID, "active-turn")).thenReturn(Optional.of(turn));
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, true, true))
|
||||
.hasMessageContaining("approval snapshot is missing");
|
||||
assertThatThrownBy(() -> connector.recoverManagedRuntime("tenant-a", SESSION_ID, false))
|
||||
.hasMessageContaining("approval snapshot is missing");
|
||||
verify(client, never()).loadSession(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleCapabilityPreflightDoesNotAttachOrSendAMutation() {
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
@@ -449,7 +567,7 @@ class QwenHostedHarnessConnectorTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "yolo", "plan", "unknown"})
|
||||
@ValueSource(strings = {" ", "plan", "unknown"})
|
||||
void invalidPersistedShellApprovalRefusesEveryAttachmentPathEvenAfterCaching(String mode) {
|
||||
var sessions = sessions();
|
||||
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null,
|
||||
@@ -479,42 +597,42 @@ class QwenHostedHarnessConnectorTest {
|
||||
when(operation.kind()).thenReturn(OperationKind.CLOSE);
|
||||
for (boolean exists : new boolean[] {false, true}) {
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, exists))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
}
|
||||
assertThatThrownBy(() -> connector.settleLifecycle(operation))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.runMessageOperation("tenant-a", SESSION_ID, Map.of(
|
||||
"operationId", "66666666-6666-4666-8666-666666666666", "kind", "stop")))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
verifyNoInteractions(client);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
|
||||
connector.createOrLoad("tenant-a", SESSION_ID, true);
|
||||
clearInvocations(client);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn(mode);
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, true))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.recoverManagedRuntime("tenant-a", SESSION_ID, false))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.recoverManagedCancellation("tenant-a", SESSION_ID))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.cancel("tenant-a", SESSION_ID))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.cancelManagedRuntime("tenant-a", SESSION_ID,
|
||||
"prompt", "checkpoint", "activation"))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.resolveAction("tenant-a", SESSION_ID, "action",
|
||||
new ObjectMapper().createObjectNode().put("optionId", "allow")
|
||||
.put("inputRevision", 1L).put("policyRevision", "hosted-tool-approval/1")))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.stream("tenant-a", SESSION_ID, 0, "epoch"))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.rename("tenant-a", SESSION_ID, "title"))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.settleLifecycle(operation))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
assertThatThrownBy(() -> connector.runMessageOperation("tenant-a", SESSION_ID, Map.of(
|
||||
"operationId", "66666666-6666-4666-8666-666666666666", "kind", "stop")))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
.hasMessageContaining("requires a valid persisted approval mode");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -720,9 +720,11 @@ class Issue13181QueryBudgetTest {
|
||||
+ " rows): " + fixture.ledger.summary());
|
||||
// Page + the latest-turn read + the workspace-close batch; without
|
||||
// turns the environment-event read is skipped, and the approval mode
|
||||
// rides on the page's own SELECT *. Model selection and context add
|
||||
// one bounded query each, regardless of the Workspace page size.
|
||||
assertThat(fixture.ledger.total()).isEqualTo(5);
|
||||
// rides on the page's own SELECT *. Model selection, context, approval
|
||||
// selection and approval ownership add one bounded query each.
|
||||
assertThat(fixture.ledger.total()).isEqualTo(7);
|
||||
assertThat(fixture.ledger.count("coalesce(selected_approval_mode, approval_mode)")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("coalesce(owner_actor_key, creator_actor_key)")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("select session_id, model_id, model_revision from ma_session")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("from ma_session_record r join ma_resource u")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count(
|
||||
@@ -821,9 +823,11 @@ class Issue13181QueryBudgetTest {
|
||||
+ " role rows): " + fixture.ledger.summary());
|
||||
// Page + latest turns + the close batch + the grant batch across
|
||||
// both workspaces + the execution-facts batch over the
|
||||
// submit-shaped sessions, plus model selection and context batches:
|
||||
// submit-shaped sessions, plus model/context and approval/owner batches:
|
||||
// constant, not per row.
|
||||
assertThat(fixture.ledger.total()).isEqualTo(7);
|
||||
assertThat(fixture.ledger.total()).isEqualTo(9);
|
||||
assertThat(fixture.ledger.count("coalesce(selected_approval_mode, approval_mode)")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("coalesce(owner_actor_key, creator_actor_key)")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("select session_id, model_id, model_revision from ma_session")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("from ma_session_record r join ma_resource u")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("from ma_workspace r join ma_workspace_access"))
|
||||
@@ -866,7 +870,9 @@ class Issue13181QueryBudgetTest {
|
||||
var page = fixture.service.listWebShellSessions(tenant, "actor", null, 20).data();
|
||||
assertThat(page).hasSize(size);
|
||||
assertThat(page).allSatisfy(row -> assertThat(JSON.valueToTree(row).path("capabilities").path("cwdChange").asBoolean()).isTrue());
|
||||
assertThat(fixture.ledger.total()).isEqualTo(7);
|
||||
assertThat(fixture.ledger.total()).isEqualTo(9);
|
||||
assertThat(fixture.ledger.count("coalesce(selected_approval_mode, approval_mode)")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("coalesce(owner_actor_key, creator_actor_key)")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("select session_id, model_id, model_revision from ma_session")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("from ma_session_record r join ma_resource u")).isEqualTo(1);
|
||||
assertThat(fixture.ledger.count("from ma_session s left join ma_workspace", "creator_actor_key")).isEqualTo(1);
|
||||
|
||||
+4
-4
@@ -326,7 +326,7 @@ class SessionRowLockOrderTest {
|
||||
// lockers no helper name would find, and the create entries, whose
|
||||
// session-row INSERT is the lock.
|
||||
assertThat(audit.lockers()).containsExactlyInAnyOrder(
|
||||
"insertTurnCommand", "insertCancelCommand", "changeSessionModel",
|
||||
"insertTurnCommand", "insertCancelCommand", "changeSessionModel", "changeSessionApproval",
|
||||
"insertSessionCommand", "insertWorkspaceSessionCommand",
|
||||
"insertChildSessionCommand",
|
||||
"insertInternalTeamLeadSessionCommand",
|
||||
@@ -377,11 +377,11 @@ class SessionRowLockOrderTest {
|
||||
"beginLifecycle", "beginOperation",
|
||||
"beginWorkspaceClose", "beginWorkspaceLifecycle",
|
||||
"bindHarness", "bindRecoveredHarness",
|
||||
"cancelBeforeAdmission", "changeSessionModel", "claimTurn",
|
||||
"cancelBeforeAdmission", "changeSessionModel", "changeSessionApproval", "claimTurn",
|
||||
"completeCwdChangeOperation", "completeHarnessTurn",
|
||||
"deferTurnRetry", "failTurn", "findActiveTurns",
|
||||
"findDispatchable", "findLatestTurns",
|
||||
"findReplayFloorTargets", "findTurn", "findTurnModelSelection",
|
||||
"findReplayFloorTargets", "findTurn", "findTurnModelSelection", "findTurnApprovalSelection",
|
||||
"findTurnSummary", "hasActiveTurn",
|
||||
"insertCancelCommand", "insertChildSession",
|
||||
"insertChildSessionCommand", "insertCsiSessionCommand",
|
||||
@@ -393,7 +393,7 @@ class SessionRowLockOrderTest {
|
||||
"materializeNextBatch", "recordAdmission",
|
||||
"recordHarnessEvents", "recordRecoveryAdmission",
|
||||
"releaseTurnLease", "renewTurn", "requireTurn",
|
||||
"requireTurnForUpdate", "retractContinuationOutput",
|
||||
"requireTurnForUpdate", "requireIdlePolicyChange", "retractContinuationOutput",
|
||||
"retractHarnessTurnOutput", "scheduleTurnRetry",
|
||||
"updateHarnessCursor", "validateOperationStart",
|
||||
"withdrawSubmissionAttempted");
|
||||
|
||||
+8
-2
@@ -44,7 +44,7 @@ class WorkspaceCreationSchemaMySqlIT {
|
||||
}
|
||||
assertEquals(1, ManagedSchema.schemaVersion(source));
|
||||
ManagedSchema.initialize(source);
|
||||
assertEquals(5, ManagedSchema.schemaVersion(source));
|
||||
assertEquals(6, ManagedSchema.schemaVersion(source));
|
||||
try (var connection = source.getConnection();
|
||||
var statement = connection.createStatement();
|
||||
var input = ManagedSchema.class.getResourceAsStream(ManagedSchema.RESOURCE)) {
|
||||
@@ -62,6 +62,12 @@ class WorkspaceCreationSchemaMySqlIT {
|
||||
statement.execute(
|
||||
"ALTER TABLE ma_request_receipt DROP CONSTRAINT ck_ma_request_namespace");
|
||||
statement.execute("ALTER TABLE ma_request_receipt ADD " + oldCheck);
|
||||
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");
|
||||
statement.execute("ALTER TABLE ma_session DROP COLUMN approval_revision");
|
||||
statement.execute("ALTER TABLE ma_turn DROP COLUMN approval_mode");
|
||||
statement.execute("ALTER TABLE ma_turn DROP COLUMN approval_revision");
|
||||
statement.execute("ALTER TABLE ma_session DROP CONSTRAINT ck_ma_session_model_selection");
|
||||
statement.execute("ALTER TABLE ma_turn DROP CONSTRAINT ck_ma_turn_model_selection");
|
||||
statement.execute("ALTER TABLE ma_session DROP COLUMN model_id");
|
||||
@@ -84,7 +90,7 @@ class WorkspaceCreationSchemaMySqlIT {
|
||||
assertEquals(4, ManagedSchema.schemaVersion(source));
|
||||
ManagedSchema.initialize(source);
|
||||
ManagedSchema.validate(source);
|
||||
assertEquals(5, ManagedSchema.schemaVersion(source));
|
||||
assertEquals(6, ManagedSchema.schemaVersion(source));
|
||||
try (var connection = source.getConnection();
|
||||
var statement = connection.createStatement()) {
|
||||
receipt(
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ class ManagedSchemaTest {
|
||||
+ "'prompt','[]','digest','COMPLETED',1,1)");
|
||||
}
|
||||
ManagedSchema.initialize(source);
|
||||
assertEquals(5, ManagedSchema.schemaVersion(source));
|
||||
assertEquals(6, 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());
|
||||
@@ -64,7 +64,7 @@ class ManagedSchemaTest {
|
||||
ManagedSchema.initialize(source);
|
||||
ManagedSchema.initialize(source);
|
||||
ManagedSchema.validate(source);
|
||||
assertEquals(5, ManagedSchema.schemaVersion(source));
|
||||
assertEquals(6, ManagedSchema.schemaVersion(source));
|
||||
try (var connection = source.getConnection()) {
|
||||
assertEquals(
|
||||
ManagedSchemaContract.TABLES, ManagedSchemaCatalog.tables(connection).keySet());
|
||||
@@ -88,7 +88,7 @@ class ManagedSchemaTest {
|
||||
() -> receipt(connection, "unsupported", "WORKSPACE_CREATE"));
|
||||
}
|
||||
ManagedSchema.initialize(source);
|
||||
assertEquals(5, ManagedSchema.schemaVersion(source));
|
||||
assertEquals(6, ManagedSchema.schemaVersion(source));
|
||||
try (var connection = source.getConnection();
|
||||
var statement = connection.createStatement()) {
|
||||
receipt(connection, "new", "WORKSPACE_CREATE");
|
||||
|
||||
+11
-3
@@ -238,8 +238,12 @@ public final class HostedHarnessClient implements AutoCloseable {
|
||||
&& !capabilities().getFeatures().contains("model-selection/1")) {
|
||||
throw new DaemonException("Hosted model selection is unavailable");
|
||||
}
|
||||
if (request.getApprovalSelection() != null
|
||||
&& !capabilities().getFeatures().contains("approval-selection/1")) {
|
||||
throw new DaemonException("Hosted approval selection is unavailable");
|
||||
}
|
||||
ActivePrompt candidate = new ActivePrompt(request.getPromptId(),
|
||||
request.getPayloadDigest(), request.getModelSelection());
|
||||
request.getPayloadDigest(), request.getModelSelection(), request.getApprovalSelection());
|
||||
ActivePrompt existing = activePrompts.putIfAbsent(
|
||||
session.getHarnessSessionId(), candidate);
|
||||
if (existing != null && !existing.matches(candidate)) {
|
||||
@@ -1576,17 +1580,21 @@ public final class HostedHarnessClient implements AutoCloseable {
|
||||
private final String promptId;
|
||||
private final String payloadDigest;
|
||||
private final Map<String, Object> modelSelection;
|
||||
private final Map<String, Object> approvalSelection;
|
||||
|
||||
ActivePrompt(String promptId, String payloadDigest, Map<String, Object> modelSelection) {
|
||||
ActivePrompt(String promptId, String payloadDigest, Map<String, Object> modelSelection,
|
||||
Map<String, Object> approvalSelection) {
|
||||
this.promptId = promptId;
|
||||
this.payloadDigest = payloadDigest;
|
||||
this.modelSelection = modelSelection;
|
||||
this.approvalSelection = approvalSelection;
|
||||
}
|
||||
|
||||
boolean matches(ActivePrompt other) {
|
||||
return promptId.equals(other.promptId)
|
||||
&& payloadDigest.equals(other.payloadDigest)
|
||||
&& java.util.Objects.equals(modelSelection, other.modelSelection);
|
||||
&& java.util.Objects.equals(modelSelection, other.modelSelection)
|
||||
&& java.util.Objects.equals(approvalSelection, other.approvalSelection);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -20,6 +20,7 @@ public final class SubmitHarnessTurn {
|
||||
private final String payloadDigest;
|
||||
private final Long deadlineMillis;
|
||||
private final Map<String, Object> modelSelection;
|
||||
private final Map<String, Object> approvalSelection;
|
||||
|
||||
private SubmitHarnessTurn(Builder builder) {
|
||||
if (builder.session == null) {
|
||||
@@ -46,6 +47,7 @@ public final class SubmitHarnessTurn {
|
||||
}
|
||||
this.deadlineMillis = builder.deadlineMillis;
|
||||
this.modelSelection = builder.modelSelection;
|
||||
this.approvalSelection = builder.approvalSelection;
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
@@ -87,6 +89,8 @@ public final class SubmitHarnessTurn {
|
||||
|
||||
Map<String, Object> getModelSelection() { return modelSelection; }
|
||||
|
||||
Map<String, Object> getApprovalSelection() { return approvalSelection; }
|
||||
|
||||
Map<String, Object> toJson() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("prompt", promptContent);
|
||||
@@ -98,6 +102,9 @@ public final class SubmitHarnessTurn {
|
||||
if (modelSelection != null) {
|
||||
result.put("modelSelection", modelSelection);
|
||||
}
|
||||
if (approvalSelection != null) {
|
||||
result.put("approvalSelection", approvalSelection);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -109,6 +116,7 @@ public final class SubmitHarnessTurn {
|
||||
private String payloadDigest;
|
||||
private Long deadlineMillis;
|
||||
private Map<String, Object> modelSelection;
|
||||
private Map<String, Object> approvalSelection;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
@@ -160,6 +168,16 @@ public final class SubmitHarnessTurn {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder approvalSelection(String mode, long revision) {
|
||||
if (mode == null || !List.of("default", "auto-edit", "yolo").contains(mode)
|
||||
|| revision < 0 || revision > 9_007_199_254_740_991L) {
|
||||
throw new IllegalArgumentException("Invalid Hosted approval selection");
|
||||
}
|
||||
approvalSelection = Collections.unmodifiableMap(new LinkedHashMap<>(
|
||||
Map.of("mode", mode, "revision", revision)));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder deadline(Duration deadline) {
|
||||
if (deadline == null || deadline.isZero()
|
||||
|| deadline.isNegative()) {
|
||||
|
||||
+45
@@ -1801,6 +1801,51 @@ class HostedHarnessClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalSelectionIsOptionalNegotiatedAndPartOfActivePromptIdentity() {
|
||||
server.createContext("/session", exchange -> sendSessionJson(exchange, 200, sessionJson()));
|
||||
capabilitiesBody.set(capabilitiesJson(DIGEST, BOOT_ID).replace("\"hostedHarness\":{",
|
||||
"\"hostedHarness\":{\"features\":[\"approval-selection/1\"],"));
|
||||
AtomicReference<String> sent = new AtomicReference<>();
|
||||
server.createContext("/session/" + SESSION_ID + "/prompt", exchange -> {
|
||||
sent.set(readBody(exchange));
|
||||
sendSessionJson(exchange, 202, "{\"promptId\":\"" + PROMPT_ID
|
||||
+ "\",\"lastEventId\":0,\"eventEpoch\":\"" + EVENT_EPOCH + "\"}");
|
||||
});
|
||||
Map<String, Object> block = Map.of("type", "text", "text", "hello");
|
||||
String digest = SubmitHarnessTurn.computePayloadDigest(List.of(block));
|
||||
try (HostedHarnessClient client = newClient()) {
|
||||
HarnessSessionRef session = createSession(client);
|
||||
var first = SubmitHarnessTurn.builder().session(session).promptId(PROMPT_ID).addContent(block)
|
||||
.payloadDigest(digest).approvalSelection("yolo", 1).build();
|
||||
client.submitTurn(first);
|
||||
assertTrue(sent.get().contains("\"approvalSelection\""));
|
||||
assertTrue(sent.get().contains("\"mode\":\"yolo\""));
|
||||
assertTrue(sent.get().contains("\"revision\":1"));
|
||||
client.submitTurn(first);
|
||||
assertThrows(DaemonException.class, () -> client.submitTurn(SubmitHarnessTurn.builder()
|
||||
.session(session).promptId(PROMPT_ID).addContent(block).payloadDigest(digest)
|
||||
.approvalSelection("auto-edit", 2).build()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> SubmitHarnessTurn.builder().approvalSelection("yolo", -1));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> SubmitHarnessTurn.builder().approvalSelection("yolo", 9_007_199_254_740_992L));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesApprovalSelectionWhenTheHostDidNotAdvertiseIt() {
|
||||
server.createContext("/session", exchange -> sendSessionJson(exchange, 200, sessionJson()));
|
||||
Map<String, Object> block = Map.of("type", "text", "text", "hello");
|
||||
try (HostedHarnessClient client = newClient()) {
|
||||
HarnessSessionRef session = createSession(client);
|
||||
var request = SubmitHarnessTurn.builder().session(session).promptId(PROMPT_ID).addContent(block)
|
||||
.payloadDigest(SubmitHarnessTurn.computePayloadDigest(List.of(block)))
|
||||
.approvalSelection("yolo", 1).build();
|
||||
assertThrows(DaemonException.class, () -> client.submitTurn(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static String capabilitiesJsonWithoutJournalToken(String digest,
|
||||
String bootId) {
|
||||
return "{\"v\":1,\"mode\":\"http-bridge\","
|
||||
|
||||
@@ -519,12 +519,16 @@ describe('build artifact — transcript entry (#11031)', () => {
|
||||
// enter the shared table. With main 17d5f774 this branch measures
|
||||
// 1,334,173 UTF-16 units; the unchanged 1,336,000 ceiling retains 1,827
|
||||
// units of margin. Managed interactive code remains excluded here.
|
||||
// Approval-policy copy (#14323) measures 1,336,282 at ebf1607d8f,
|
||||
// versus 1,334,735 at its main parent 87de74860b. The 1,547-unit
|
||||
// increase is shared i18n copy; substituting main's table restores the
|
||||
// baseline. A 1,338,000 ceiling retains 1,718 units of margin.
|
||||
// Re-measure and lower it if the entry gets leaner.
|
||||
const js = readTranscriptBundle().replace(
|
||||
/^const __qwenWebShellCss=[^\n]*\n/,
|
||||
'',
|
||||
);
|
||||
expect(js.length).toBeLessThan(1_336_000);
|
||||
expect(js.length).toBeLessThan(1_338_000);
|
||||
});
|
||||
|
||||
it('keeps workspace mutations out of the read-only transcript entry', () => {
|
||||
|
||||
@@ -406,6 +406,112 @@ describe('ManagedSessionsPage', () => {
|
||||
mocks.client.getSession.mockImplementation(async () => current());
|
||||
}
|
||||
|
||||
it('switches approval after readback while preserving the draft and excluding same-frame send and model changes', async () => {
|
||||
let session = switchableSession({
|
||||
approvalMode: 'default',
|
||||
initialApprovalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
capabilities: {
|
||||
canSend: true,
|
||||
canCancel: false,
|
||||
workspaceTurns: true,
|
||||
modelChange: true,
|
||||
approvalModeChange: true,
|
||||
},
|
||||
});
|
||||
modelApi(() => session);
|
||||
let acknowledge!: (value: {
|
||||
sessionId: string;
|
||||
approvalMode: 'yolo';
|
||||
approvalRevision: number;
|
||||
replayed: boolean;
|
||||
}) => void;
|
||||
provider.approvalChange = {
|
||||
queryOptions: vi.fn(async () => ({
|
||||
supported: true,
|
||||
approvalMode: session.approvalMode!,
|
||||
approvalRevision: session.approvalRevision!,
|
||||
availableApprovalModes: ['default', 'auto-edit', 'yolo'],
|
||||
})),
|
||||
submit: vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
acknowledge = resolve;
|
||||
}),
|
||||
),
|
||||
};
|
||||
await render('s1');
|
||||
await input('Keep the approval draft');
|
||||
expect(mocks.editorProps?.modeControlsDisabled).toBe(false);
|
||||
expect(mocks.editorProps?.availableModes).toEqual([
|
||||
'default',
|
||||
'auto-edit',
|
||||
'yolo',
|
||||
]);
|
||||
const send = mocks.editorProps!.onSubmit;
|
||||
const model = mocks.editorProps!.onSelectModel!;
|
||||
await act(async () => {
|
||||
mocks.editorProps!.onSelectMode!('yolo');
|
||||
send('Keep the approval draft', undefined, undefined, vi.fn());
|
||||
model('B');
|
||||
await flush();
|
||||
});
|
||||
expect(mocks.client.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(provider.modelChange!.submit).not.toHaveBeenCalled();
|
||||
expect(mocks.editorProps?.currentMode).toBe('default');
|
||||
expect(mocks.editorProps?.modeControlsDisabled).toBe(true);
|
||||
expect(mocks.editorProps?.modelControlsDisabled).toBe(true);
|
||||
session = { ...session, approvalMode: 'yolo', approvalRevision: 1 };
|
||||
await act(async () => {
|
||||
acknowledge({
|
||||
sessionId: 's1',
|
||||
approvalMode: 'yolo',
|
||||
approvalRevision: 1,
|
||||
replayed: false,
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
expect(mocks.editorProps?.currentMode).toBe('yolo');
|
||||
expect(mocks.editorProps?.modeControlsDisabled).toBe(false);
|
||||
expect(container.querySelector('textarea')?.value).toBe(
|
||||
'Keep the approval draft',
|
||||
);
|
||||
expect(mocks.commitAccepted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['agent_running', 'cancelling'] as const)(
|
||||
'refuses approval callbacks during a %s Turn',
|
||||
async (phase) => {
|
||||
const session = switchableSession({
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
phase,
|
||||
capabilities: {
|
||||
canSend: true,
|
||||
canCancel: true,
|
||||
workspaceTurns: true,
|
||||
approvalModeChange: true,
|
||||
},
|
||||
});
|
||||
mocks.client.getSession.mockResolvedValue(session);
|
||||
provider.approvalChange = {
|
||||
queryOptions: vi.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
availableApprovalModes: ['default', 'auto-edit', 'yolo'],
|
||||
}),
|
||||
submit: vi.fn(),
|
||||
};
|
||||
await render('s1');
|
||||
expect(mocks.editorProps?.modeControlsDisabled).toBe(true);
|
||||
await act(async () => {
|
||||
mocks.editorProps!.onSelectMode!('yolo');
|
||||
});
|
||||
expect(provider.approvalChange.submit).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('switches only after admission and readback, preserves the draft and blocks a same-frame send', async () => {
|
||||
let session = switchableSession();
|
||||
modelApi(() => session);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useManagedSession } from './use-managed-session';
|
||||
import { useManagedActions } from './use-managed-actions';
|
||||
import { useManagedCwdChange } from './use-managed-cwd-change';
|
||||
import { useManagedModelChange } from './use-managed-model-change';
|
||||
import { useManagedApprovalChange } from './use-managed-approval-change';
|
||||
import { ManagedSessionCwdControl } from './ManagedSessionCwdControl';
|
||||
import { toManagedPermissionRequest } from './managed-approval';
|
||||
import { isNonRetryableClientError } from './managed-request-error';
|
||||
@@ -266,6 +267,14 @@ function ManagedSessionsContent({
|
||||
detail.refreshSummary,
|
||||
interactive,
|
||||
);
|
||||
const approvalChange = useManagedApprovalChange(
|
||||
provider,
|
||||
clientId,
|
||||
sessionId,
|
||||
detail.summary,
|
||||
detail.refreshSummary,
|
||||
interactive,
|
||||
);
|
||||
// The reason line below is mounted exactly when this holds, so the dialog can
|
||||
// point at it without ever leaving a dangling IDREF. A latch-only render has
|
||||
// no `alert` node and no operable option left, so this line is the only place
|
||||
@@ -401,7 +410,8 @@ function ManagedSessionsContent({
|
||||
abort.signal.aborted ||
|
||||
busy ||
|
||||
cwd.isBlocked() ||
|
||||
modelChange.isBlocked()
|
||||
modelChange.isBlocked() ||
|
||||
approvalChange.isBlocked()
|
||||
)
|
||||
return;
|
||||
setDiscarded(false);
|
||||
@@ -853,6 +863,7 @@ function ManagedSessionsContent({
|
||||
pendingApproval ||
|
||||
busy ||
|
||||
modelChange.blocked ||
|
||||
approvalChange.blocked ||
|
||||
pending?.sessionId === sessionId
|
||||
? t('managed.cwd.busy')
|
||||
: undefined
|
||||
@@ -861,6 +872,7 @@ function ManagedSessionsContent({
|
||||
if (
|
||||
pendingRef.current?.sessionId === sessionId ||
|
||||
modelChange.isBlocked() ||
|
||||
approvalChange.isBlocked() ||
|
||||
busy ||
|
||||
active ||
|
||||
pendingApproval
|
||||
@@ -1128,7 +1140,9 @@ function ManagedSessionsContent({
|
||||
!modelChange.options?.supported ||
|
||||
modelChange.optionsLoading ||
|
||||
modelChange.busy ||
|
||||
approvalChange.busy ||
|
||||
modelChange.blocked ||
|
||||
approvalChange.blocked ||
|
||||
busy ||
|
||||
Boolean(active) ||
|
||||
Boolean(pendingApproval) ||
|
||||
@@ -1146,7 +1160,8 @@ function ManagedSessionsContent({
|
||||
pendingRef.current ||
|
||||
active ||
|
||||
pendingApproval ||
|
||||
cwd.isBlocked()
|
||||
cwd.isBlocked() ||
|
||||
approvalChange.isBlocked()
|
||||
)
|
||||
return;
|
||||
void modelChange.submit(modelId);
|
||||
@@ -1161,11 +1176,52 @@ function ManagedSessionsContent({
|
||||
contextUsageAlwaysVisible={Boolean(sessionId)}
|
||||
currentMode={summary?.approvalMode ?? ''}
|
||||
availableModes={
|
||||
summary?.approvalMode ? [summary.approvalMode] : []
|
||||
approvalChange.options?.availableApprovalModes ??
|
||||
(summary?.approvalMode ? [summary.approvalMode] : [])
|
||||
}
|
||||
modeControlsDisabled={
|
||||
!interactive ||
|
||||
!approvalChange.supported ||
|
||||
!approvalChange.options?.supported ||
|
||||
approvalChange.optionsLoading ||
|
||||
approvalChange.busy ||
|
||||
approvalChange.blocked ||
|
||||
busy ||
|
||||
Boolean(active) ||
|
||||
Boolean(pendingApproval) ||
|
||||
Boolean(pending) ||
|
||||
cwd.blocked ||
|
||||
modelChange.blocked ||
|
||||
modelChange.busy ||
|
||||
approvalChange.options.approvalRevision !==
|
||||
(summary?.approvalRevision ?? 0)
|
||||
}
|
||||
onSelectMode={
|
||||
approvalChange.supported
|
||||
? (mode) => {
|
||||
if (
|
||||
!interactive ||
|
||||
busy ||
|
||||
pendingRef.current ||
|
||||
active ||
|
||||
pendingApproval ||
|
||||
cwd.isBlocked() ||
|
||||
modelChange.isBlocked()
|
||||
)
|
||||
return;
|
||||
if (
|
||||
mode === 'default' ||
|
||||
mode === 'auto-edit' ||
|
||||
mode === 'yolo'
|
||||
)
|
||||
void approvalChange.submit(mode);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
modeControlsDisabled
|
||||
showChatWidthToggle={false}
|
||||
isPreparing={busy || cwd.blocked || modelChange.busy}
|
||||
isPreparing={
|
||||
busy || cwd.blocked || modelChange.busy || approvalChange.busy
|
||||
}
|
||||
dialogOpen={!interactive}
|
||||
isRunning={home && Boolean(active)}
|
||||
onCancel={
|
||||
@@ -1181,6 +1237,7 @@ function ManagedSessionsContent({
|
||||
busy ||
|
||||
Boolean(pending) ||
|
||||
modelChange.blocked ||
|
||||
approvalChange.blocked ||
|
||||
Boolean(sessionId && !summary?.capabilities.canSend)
|
||||
}
|
||||
placeholderText={t('managed.prompt')}
|
||||
@@ -1247,12 +1304,77 @@ function ManagedSessionsContent({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{interactive &&
|
||||
(approvalChange.supported || approvalChange.blocked) && (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
approvalChange.busy
|
||||
? 'managed.approvalChange.changing'
|
||||
: approvalChange.blocked
|
||||
? 'managed.approvalChange.unconfirmed'
|
||||
: approvalChange.optionsLoading
|
||||
? 'managed.approvalChange.loading'
|
||||
: approvalChange.optionsError
|
||||
? 'managed.approvalChange.optionsError'
|
||||
: 'managed.approvalChange.nextTurn',
|
||||
)}
|
||||
</span>
|
||||
{approvalChange.pending && (
|
||||
<span>{approvalChange.pending.approvalMode}</span>
|
||||
)}
|
||||
{approvalChange.blocked && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
approvalChange.busy || !provider.approvalChange
|
||||
}
|
||||
onClick={() => void approvalChange.confirm()}
|
||||
>
|
||||
{t('managed.approvalChange.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
{approvalChange.optionsError && !approvalChange.blocked && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={approvalChange.reloadOptions}
|
||||
>
|
||||
{t('managed.approvalChange.reload')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{interactive &&
|
||||
approvalChange.errorCode &&
|
||||
approvalChange.errorCode !== 'unconfirmed' && (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{t(
|
||||
approvalChange.errorCode === 'storage_unavailable'
|
||||
? 'managed.approvalChange.storage'
|
||||
: approvalChange.errorCode ===
|
||||
'approval_selection_conflict'
|
||||
? 'managed.approvalChange.conflict'
|
||||
: ['turn_active', 'session_context_busy'].includes(
|
||||
approvalChange.errorCode,
|
||||
)
|
||||
? 'managed.approvalChange.busy'
|
||||
: 'managed.approvalChange.failed',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{displayedModel && !modelChange.supported && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('managed.modelFixed')}
|
||||
</p>
|
||||
)}
|
||||
{summary?.approvalMode && (
|
||||
{summary?.approvalMode && !approvalChange.supported && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('managed.approvalFixed')}
|
||||
</p>
|
||||
|
||||
@@ -483,6 +483,22 @@ describe('WorkspaceBindingCreator', () => {
|
||||
expect(editorMocks.props?.visibleToolbarActions).toContain('approvalMode');
|
||||
});
|
||||
|
||||
it('confirms creation against the immutable initial policy after a later switch', async () => {
|
||||
approvalSelection();
|
||||
getSession.mockResolvedValue({
|
||||
sessionId: 'session-a',
|
||||
agentId: 'agent-a',
|
||||
approvalMode: 'yolo',
|
||||
initialApprovalMode: 'default',
|
||||
approvalRevision: 2,
|
||||
workspace: { workspaceId: 'ws-a', cwdRelative: '.' },
|
||||
});
|
||||
await render();
|
||||
const commit = await sendInitial('Preserve the initial selection');
|
||||
expect(onCreated).toHaveBeenCalled();
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps explicit policy frozen when Session read-back reports another mode', async () => {
|
||||
const { createSession } = approvalSelection();
|
||||
getSession.mockResolvedValue({
|
||||
|
||||
@@ -491,7 +491,8 @@ export function WorkspaceBindingCreator({
|
||||
if (
|
||||
session.sessionId !== createdSessionId ||
|
||||
(attempt.approvalMode !== undefined &&
|
||||
session.approvalMode !== attempt.approvalMode) ||
|
||||
(session.initialApprovalMode ?? session.approvalMode) !==
|
||||
attempt.approvalMode) ||
|
||||
((modelPresets.length > 0 || session.agentId !== undefined) &&
|
||||
session.agentId !== attempt.agentId) ||
|
||||
!session.workspace ||
|
||||
|
||||
@@ -482,6 +482,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/agent/web-shell/v1/session-approval/query": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Read the current policy and owner-authorized change support of a readable Workspace Session. */
|
||||
post: operations["queryWebShellSessionApproval"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/agent/web-shell/v1/session-approval/change": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Change the next-Turn policy of an idle built-in Workspace Session. Requires its recorded owner and current OPERATOR access. Changes are revision-checked and actor/session-scoped idempotent. */
|
||||
post: operations["changeWebShellSessionApproval"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -616,12 +650,19 @@ export interface components {
|
||||
workspace?: components["schemas"]["WebShellWorkspaceContext"];
|
||||
capabilities: components["schemas"]["WebShellSessionCapabilities"];
|
||||
/**
|
||||
* @description Persisted Session approval policy; older servers may omit it. Existing Sessions cannot change policy through this API.
|
||||
* @description Current effective approval policy; initialApprovalMode retains the immutable creation policy.
|
||||
* @enum {string}
|
||||
*/
|
||||
approvalMode?: "default" | "auto-edit" | "yolo";
|
||||
modelContext?: components["schemas"]["WebShellModelContext"];
|
||||
modelSelection?: components["schemas"]["WebShellModelSelection"];
|
||||
/** Format: int64 */
|
||||
approvalRevision?: number;
|
||||
/**
|
||||
* @description Immutable creation-time policy, used to confirm creation replay.
|
||||
* @enum {string}
|
||||
*/
|
||||
initialApprovalMode?: "default" | "auto-edit" | "yolo";
|
||||
};
|
||||
WebShellSessionCapabilities: {
|
||||
/**
|
||||
@@ -658,10 +699,12 @@ export interface components {
|
||||
* @default false
|
||||
*/
|
||||
sessionDelete?: boolean;
|
||||
/** @description Present only for persisted Shell Sessions, with no schema default; files and unbound Sessions omit it. True when Workspace files, Shell deployment admission and persisted default/auto-edit approval are valid. False prevents fresh sending. Disabling only the Shell flag preserves accepted cancellation and approval while Workspace files remain enabled and persisted approval remains valid; files-off or invalid approval can refuse attachment. This capability does not override authorization or recovery refusals. */
|
||||
/** @description Present only for persisted Shell Sessions, with no schema default; files and unbound Sessions omit it. True when Workspace files, Shell deployment admission and a valid persisted approval mode are valid. False prevents fresh sending. Disabling only the Shell flag preserves accepted cancellation and approval while Workspace files remain enabled and persisted approval remains valid; files-off or invalid approval can refuse attachment. This capability does not override authorization or recovery refusals. */
|
||||
foregroundShell?: boolean;
|
||||
/** @description The caller may request an idle Session model change using the server model catalog. Missing means unsupported; authorization and revision admission still apply. */
|
||||
modelChange?: boolean;
|
||||
/** @description The caller owns this built-in Workspace Session with OPERATOR access and the Harness supports approval-selection/1. Idle-state admission is checked separately. Missing means false. */
|
||||
approvalModeChange?: boolean;
|
||||
};
|
||||
WebShellSessionPage: {
|
||||
data: components["schemas"]["WebShellSession"][];
|
||||
@@ -1240,6 +1283,34 @@ export interface components {
|
||||
modelSelection: components["schemas"]["WebShellModelSelection"];
|
||||
replayed: boolean;
|
||||
};
|
||||
WebShellSessionApprovalRequest: {
|
||||
sessionId: string;
|
||||
};
|
||||
WebShellSessionApproval: {
|
||||
/** @enum {string} */
|
||||
approvalMode: "default" | "auto-edit" | "yolo";
|
||||
/** Format: int64 */
|
||||
approvalRevision: number;
|
||||
availableApprovalModes: ("default" | "auto-edit" | "yolo")[];
|
||||
supported: boolean;
|
||||
};
|
||||
WebShellChangeApprovalRequest: {
|
||||
sessionId: string;
|
||||
/** Format: int64 */
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
requestId?: string;
|
||||
/** @enum {string} */
|
||||
approvalMode: "default" | "auto-edit" | "yolo";
|
||||
};
|
||||
WebShellApprovalChangeResult: {
|
||||
sessionId: string;
|
||||
/** @enum {string} */
|
||||
approvalMode: "default" | "auto-edit" | "yolo";
|
||||
/** Format: int64 */
|
||||
approvalRevision: number;
|
||||
replayed: boolean;
|
||||
};
|
||||
};
|
||||
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. */
|
||||
@@ -2297,4 +2368,64 @@ export interface operations {
|
||||
413: components["responses"]["PayloadTooLarge"];
|
||||
};
|
||||
};
|
||||
queryWebShellSessionApproval: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebShellSessionApprovalRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description The current policy options. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebShellSessionApproval"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
413: components["responses"]["PayloadTooLarge"];
|
||||
};
|
||||
};
|
||||
changeWebShellSessionApproval: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebShellChangeApprovalRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description The committed approval selection, or its original receipt on replay. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebShellApprovalChangeResult"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
413: components["responses"]["PayloadTooLarge"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -149,6 +149,20 @@ export class JavaManagedAgentClient {
|
||||
return this.post('/workspaces/create', request, signal);
|
||||
}
|
||||
|
||||
getSessionApproval(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Schemas['WebShellSessionApproval']> {
|
||||
return this.post('/session-approval/query', { sessionId }, signal);
|
||||
}
|
||||
|
||||
changeApproval(
|
||||
request: Schemas['WebShellChangeApprovalRequest'],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Schemas['WebShellApprovalChangeResult']> {
|
||||
return this.post('/session-approval/change', request, signal);
|
||||
}
|
||||
|
||||
getSessionOptions(
|
||||
agentId: string,
|
||||
signal?: AbortSignal,
|
||||
|
||||
+36
-33
@@ -13,39 +13,42 @@ import type {
|
||||
} from './java-managed-agent-client';
|
||||
|
||||
describe('java managed agent event projector', () => {
|
||||
it('projects a context change without creating or settling a chat message', () => {
|
||||
const context = projectJavaAgentEvent({
|
||||
sequence: 2,
|
||||
eventId: 'e2',
|
||||
sessionId: 's1',
|
||||
type: 'session.context.changed',
|
||||
createdAt: 2,
|
||||
terminal: false,
|
||||
data: {
|
||||
workspaceId: 'ws',
|
||||
cwdRelative: 'B',
|
||||
contextRevision: 2,
|
||||
operationId: 'op',
|
||||
},
|
||||
});
|
||||
expect(context).toMatchObject({ type: 'context_changed', turnId: '' });
|
||||
const delta = {
|
||||
id: 1,
|
||||
at: 1,
|
||||
sessionId: 's1',
|
||||
turnId: 't1',
|
||||
type: 'assistant_delta' as const,
|
||||
data: { text: 'hello' },
|
||||
};
|
||||
expect(
|
||||
managedEventsToMessages(
|
||||
[delta, context!, { ...delta, id: 3, data: { text: ' world' } }],
|
||||
'',
|
||||
),
|
||||
).toMatchObject([
|
||||
{ role: 'assistant', content: 'hello world', isStreaming: true },
|
||||
]);
|
||||
});
|
||||
it.each(['session.context.changed', 'session.approval.changed'])(
|
||||
'projects %s without creating or settling a chat message',
|
||||
(type) => {
|
||||
const context = projectJavaAgentEvent({
|
||||
sequence: 2,
|
||||
eventId: 'e2',
|
||||
sessionId: 's1',
|
||||
type,
|
||||
createdAt: 2,
|
||||
terminal: false,
|
||||
data: {
|
||||
workspaceId: 'ws',
|
||||
cwdRelative: 'B',
|
||||
contextRevision: 2,
|
||||
operationId: 'op',
|
||||
},
|
||||
});
|
||||
expect(context).toMatchObject({ type: 'context_changed', turnId: '' });
|
||||
const delta = {
|
||||
id: 1,
|
||||
at: 1,
|
||||
sessionId: 's1',
|
||||
turnId: 't1',
|
||||
type: 'assistant_delta' as const,
|
||||
data: { text: 'hello' },
|
||||
};
|
||||
expect(
|
||||
managedEventsToMessages(
|
||||
[delta, context!, { ...delta, id: 3, data: { text: ' world' } }],
|
||||
'',
|
||||
),
|
||||
).toMatchObject([
|
||||
{ role: 'assistant', content: 'hello world', isStreaming: true },
|
||||
]);
|
||||
},
|
||||
);
|
||||
it('recovers the same tool from real Java publication, snapshot and event responses', () => {
|
||||
// Exported by ManagedArtifactApiIntegrationTest after publication/receipt and SQL projection.
|
||||
const live = javaFixture.events.flatMap((event) => {
|
||||
|
||||
@@ -136,6 +136,7 @@ function eventType(
|
||||
case 'action.updated':
|
||||
return 'action_updated';
|
||||
case 'session.context.changed':
|
||||
case 'session.approval.changed':
|
||||
return 'context_changed';
|
||||
default:
|
||||
return undefined;
|
||||
|
||||
@@ -10,6 +10,99 @@ function jsonResponse(value: unknown): Response {
|
||||
}
|
||||
|
||||
describe('createJavaManagedAgentProvider', () => {
|
||||
it('uses the WebShell approval contract and preserves the exact request id and idempotency key', async () => {
|
||||
const selection = {
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
availableApprovalModes: ['default', 'auto-edit', 'yolo'],
|
||||
supported: true,
|
||||
};
|
||||
const receipt = {
|
||||
sessionId: 's',
|
||||
approvalMode: 'yolo',
|
||||
approvalRevision: 1,
|
||||
replayed: true,
|
||||
};
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ ...selection, privateRoute: 'private' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ ...receipt, privateRoute: 'private' }),
|
||||
);
|
||||
const provider = createJavaManagedAgentProvider({
|
||||
baseUrl: 'https://product.example',
|
||||
productScope: 'tenant:actor',
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
expect(
|
||||
await provider.approvalChange!.queryOptions('s', { clientId: 'client' }),
|
||||
).toEqual(selection);
|
||||
expect(
|
||||
await provider.approvalChange!.submit(
|
||||
's',
|
||||
{ approvalMode: 'yolo', expectedRevision: 0 },
|
||||
{
|
||||
clientId: 'client',
|
||||
idempotencyKey: 'original-key',
|
||||
requestId: 'original-request',
|
||||
},
|
||||
),
|
||||
).toEqual(receipt);
|
||||
expect(fetchImpl.mock.calls.map(([url]) => String(url))).toEqual([
|
||||
'https://product.example/api/agent/web-shell/v1/session-approval/query',
|
||||
'https://product.example/api/agent/web-shell/v1/session-approval/change',
|
||||
]);
|
||||
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({
|
||||
sessionId: 's',
|
||||
approvalMode: 'yolo',
|
||||
expectedRevision: 0,
|
||||
idempotencyKey: 'original-key',
|
||||
requestId: 'original-request',
|
||||
});
|
||||
expect(
|
||||
createJavaManagedAgentProvider({ baseUrl: 'https://product.example' })
|
||||
.approvalChange,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
approvalMode: ['yolo'],
|
||||
approvalRevision: 0,
|
||||
availableApprovalModes: ['yolo'],
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
approvalMode: 'yolo',
|
||||
approvalRevision: -1,
|
||||
availableApprovalModes: ['yolo'],
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
approvalMode: 'yolo',
|
||||
approvalRevision: 0,
|
||||
availableApprovalModes: ['default'],
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
approvalMode: 'yolo',
|
||||
approvalRevision: 0,
|
||||
availableApprovalModes: ['yolo', 'auto'],
|
||||
supported: true,
|
||||
},
|
||||
])('refuses invalid approval options (%j)', async (selection) => {
|
||||
const provider = createJavaManagedAgentProvider({
|
||||
baseUrl: 'https://product.example',
|
||||
productScope: 'tenant:actor',
|
||||
fetch: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(selection)),
|
||||
});
|
||||
await expect(
|
||||
provider.approvalChange!.queryOptions('s', { clientId: 'client' }),
|
||||
).rejects.toThrow('Invalid Session approval options');
|
||||
});
|
||||
|
||||
it('uses only server model choices and preserves the frozen change key and revision', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
|
||||
@@ -198,6 +198,55 @@ export function createJavaManagedAgentProvider(
|
||||
},
|
||||
...(options.productScope?.trim()
|
||||
? {
|
||||
approvalChange: {
|
||||
async queryOptions(sessionId, request) {
|
||||
const result = await client.getSessionApproval(
|
||||
sessionId,
|
||||
request.signal,
|
||||
);
|
||||
if (
|
||||
!isManagedApprovalMode(result.approvalMode) ||
|
||||
!Number.isSafeInteger(result.approvalRevision) ||
|
||||
result.approvalRevision < 0 ||
|
||||
typeof result.supported !== 'boolean' ||
|
||||
!Array.isArray(result.availableApprovalModes) ||
|
||||
!result.availableApprovalModes.every(isManagedApprovalMode) ||
|
||||
!result.availableApprovalModes.includes(result.approvalMode)
|
||||
)
|
||||
throw new Error('Invalid Session approval options');
|
||||
return {
|
||||
approvalMode: result.approvalMode,
|
||||
approvalRevision: result.approvalRevision,
|
||||
availableApprovalModes: result.availableApprovalModes,
|
||||
supported: result.supported,
|
||||
};
|
||||
},
|
||||
async submit(sessionId, request, command) {
|
||||
const result = await client.changeApproval(
|
||||
{
|
||||
...request,
|
||||
sessionId,
|
||||
idempotencyKey: command.idempotencyKey,
|
||||
requestId: command.requestId,
|
||||
},
|
||||
command.signal,
|
||||
);
|
||||
if (
|
||||
result.sessionId !== sessionId ||
|
||||
result.approvalMode !== request.approvalMode ||
|
||||
!Number.isSafeInteger(result.approvalRevision) ||
|
||||
result.approvalRevision <= request.expectedRevision ||
|
||||
typeof result.replayed !== 'boolean'
|
||||
)
|
||||
throw new Error('Approval change identity mismatch');
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
approvalMode: result.approvalMode,
|
||||
approvalRevision: result.approvalRevision,
|
||||
replayed: result.replayed,
|
||||
};
|
||||
},
|
||||
},
|
||||
modelChange: {
|
||||
async queryOptions(sessionId, request) {
|
||||
const result = await client.getSessionModels(
|
||||
@@ -535,6 +584,13 @@ function toSessionSummary(
|
||||
...(isManagedApprovalMode(session.approvalMode)
|
||||
? { approvalMode: session.approvalMode }
|
||||
: {}),
|
||||
...(isManagedApprovalMode(session.initialApprovalMode)
|
||||
? { initialApprovalMode: session.initialApprovalMode }
|
||||
: {}),
|
||||
...(Number.isSafeInteger(session.approvalRevision) &&
|
||||
Number(session.approvalRevision) >= 0
|
||||
? { approvalRevision: session.approvalRevision }
|
||||
: {}),
|
||||
...(observation ? { modelContext: observation } : {}),
|
||||
...(selectedModel ? { modelSelection: selectedModel } : {}),
|
||||
activeTurnId: session.activeTurn?.turnId,
|
||||
@@ -565,6 +621,9 @@ function toSessionSummary(
|
||||
...(session.capabilities?.modelChange === true
|
||||
? { modelChange: true }
|
||||
: {}),
|
||||
...(session.capabilities?.approvalModeChange === true
|
||||
? { approvalModeChange: true }
|
||||
: {}),
|
||||
...(session.capabilities?.actions === true ? { actions: true } : {}),
|
||||
},
|
||||
...(errorCode ? { failure: { code: errorCode, message: errorCode } } : {}),
|
||||
|
||||
@@ -32,6 +32,18 @@ export interface ManagedSessionOptions {
|
||||
availableApprovalModes: ManagedApprovalMode[];
|
||||
}
|
||||
|
||||
export interface ManagedSessionApproval extends ManagedSessionOptions {
|
||||
approvalRevision: number;
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedApprovalChangeResult {
|
||||
sessionId: string;
|
||||
approvalMode: ManagedApprovalMode;
|
||||
approvalRevision: number;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedModelContext {
|
||||
modelId: string;
|
||||
inputTokens?: number;
|
||||
@@ -60,6 +72,8 @@ export interface ManagedAgentSessionSummary {
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
approvalMode?: ManagedApprovalMode;
|
||||
initialApprovalMode?: ManagedApprovalMode;
|
||||
approvalRevision?: number;
|
||||
modelContext?: ManagedModelContext;
|
||||
modelSelection?: ManagedModelSelection;
|
||||
activeTurnId?: string;
|
||||
@@ -87,6 +101,7 @@ export interface ManagedAgentSessionSummary {
|
||||
workspaceTurns?: boolean;
|
||||
cwdChange?: boolean;
|
||||
modelChange?: boolean;
|
||||
approvalModeChange?: boolean;
|
||||
};
|
||||
failure?: { code: string; message: string };
|
||||
}
|
||||
@@ -230,6 +245,17 @@ export interface ManagedAgentProvider {
|
||||
options: ManagedAgentRequestOptions,
|
||||
): Promise<ManagedCwdOperation>;
|
||||
};
|
||||
readonly approvalChange?: {
|
||||
queryOptions(
|
||||
sessionId: string,
|
||||
options: ManagedAgentRequestOptions,
|
||||
): Promise<ManagedSessionApproval>;
|
||||
submit(
|
||||
sessionId: string,
|
||||
request: { approvalMode: ManagedApprovalMode; expectedRevision: number },
|
||||
options: ManagedAgentCommandOptions & { requestId: string },
|
||||
): Promise<ManagedApprovalChangeResult>;
|
||||
};
|
||||
readonly modelChange?: {
|
||||
queryOptions(
|
||||
sessionId: string,
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
||||
import type {
|
||||
ManagedAgentProvider,
|
||||
ManagedAgentSessionSummary,
|
||||
ManagedSessionApproval,
|
||||
} from './managed-agent-provider';
|
||||
import { useManagedApprovalChange } from './use-managed-approval-change';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const summary: ManagedAgentSessionSummary = {
|
||||
sessionId: 's1',
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
title: 'Task',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
admittedAt: 1,
|
||||
phase: 'completed',
|
||||
runtimeReady: true,
|
||||
runtimeState: 'ready',
|
||||
workspace: { workspaceId: 'workspace', cwdRelative: '.' },
|
||||
capabilities: { canSend: true, canCancel: false, approvalModeChange: true },
|
||||
};
|
||||
const options: ManagedSessionApproval = {
|
||||
supported: true,
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
availableApprovalModes: ['default', 'auto-edit', 'yolo'],
|
||||
};
|
||||
const result = {
|
||||
sessionId: 's1',
|
||||
approvalMode: 'yolo' as const,
|
||||
approvalRevision: 1,
|
||||
replayed: false,
|
||||
};
|
||||
const changed = {
|
||||
...summary,
|
||||
approvalMode: result.approvalMode,
|
||||
approvalRevision: result.approvalRevision,
|
||||
};
|
||||
const key = 'qwen-managed-approval:tenant:actor:s1';
|
||||
|
||||
describe('useManagedApprovalChange', () => {
|
||||
let root: Root;
|
||||
let current: ReturnType<typeof useManagedApprovalChange>;
|
||||
let provider: ManagedAgentProvider;
|
||||
let refresh: ReturnType<typeof vi.fn>;
|
||||
function Probe({
|
||||
session = summary,
|
||||
interactive = true,
|
||||
}: {
|
||||
session?: ManagedAgentSessionSummary;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
current = useManagedApprovalChange(
|
||||
provider,
|
||||
'client',
|
||||
session.sessionId,
|
||||
session,
|
||||
refresh,
|
||||
interactive,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
async function render(session = summary, interactive = true) {
|
||||
await act(async () => {
|
||||
root.render(<Probe session={session} interactive={interactive} />);
|
||||
});
|
||||
}
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
root = createRoot(document.createElement('div'));
|
||||
provider = {
|
||||
storageKey: 'tenant:actor',
|
||||
approvalChange: {
|
||||
queryOptions: vi.fn().mockResolvedValue(options),
|
||||
submit: vi.fn().mockResolvedValue(result),
|
||||
},
|
||||
} as unknown as ManagedAgentProvider;
|
||||
refresh = vi.fn().mockResolvedValue(changed);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('persists before sending and keeps the lock until an acknowledged revision is read back', async () => {
|
||||
const read = deferred<ManagedAgentSessionSummary>();
|
||||
refresh.mockReturnValue(read.promise);
|
||||
vi.mocked(provider.approvalChange!.submit).mockImplementation(
|
||||
async (_id, request, command) => {
|
||||
expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual({
|
||||
sessionId: 's1',
|
||||
...request,
|
||||
idempotencyKey: command.idempotencyKey,
|
||||
requestId: command.requestId,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
await render();
|
||||
let pending!: ReturnType<typeof current.submit>;
|
||||
await act(async () => {
|
||||
pending = current.submit('yolo');
|
||||
expect(current.isBlocked()).toBe(true);
|
||||
void current.submit('default');
|
||||
});
|
||||
expect(current.blocked).toBe(true);
|
||||
expect(provider.approvalChange!.submit).toHaveBeenCalledOnce();
|
||||
await act(async () => {
|
||||
read.resolve(changed);
|
||||
await pending;
|
||||
});
|
||||
expect(current.blocked).toBe(false);
|
||||
expect(sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
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'),
|
||||
);
|
||||
await render();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
const saved: unknown = JSON.parse(sessionStorage.getItem(key)!);
|
||||
expect(current.errorCode).toBe('unconfirmed');
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(document.createElement('div'));
|
||||
await render();
|
||||
expect(provider.approvalChange!.submit).toHaveBeenCalledOnce();
|
||||
expect(current.pending).toEqual(saved);
|
||||
await act(async () => {
|
||||
await current.confirm();
|
||||
});
|
||||
const calls = vi.mocked(provider.approvalChange!.submit).mock.calls;
|
||||
expect(calls[1][0]).toBe(calls[0][0]);
|
||||
expect(calls[1][1]).toEqual(calls[0][1]);
|
||||
expect(calls[1][2].idempotencyKey).toBe(calls[0][2].idempotencyKey);
|
||||
expect(calls[1][2].requestId).toBe(calls[0][2].requestId);
|
||||
expect(current.blocked).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'approval_selection_conflict',
|
||||
'turn_active',
|
||||
'session_context_busy',
|
||||
'unsupported_approval_mode',
|
||||
'session_operation_forbidden',
|
||||
])(
|
||||
'releases a definitively refused key and refreshes options without resubmission (%s)',
|
||||
async (code) => {
|
||||
vi.mocked(provider.approvalChange!.submit).mockRejectedValue(
|
||||
new JavaManagedAgentHttpError(409, code, code),
|
||||
);
|
||||
await render();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
expect(current.blocked).toBe(false);
|
||||
expect(current.errorCode).toBe(code);
|
||||
expect(provider.approvalChange!.submit).toHaveBeenCalledOnce();
|
||||
expect(provider.approvalChange!.queryOptions).toHaveBeenCalledTimes(2);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([401, 404, 500, 409])(
|
||||
'retains an uncertain key after authentication, availability or idempotency failures (%s)',
|
||||
async (status) => {
|
||||
vi.mocked(provider.approvalChange!.submit).mockRejectedValue(
|
||||
new JavaManagedAgentHttpError(
|
||||
status,
|
||||
'idempotency_conflict',
|
||||
'uncertain',
|
||||
),
|
||||
);
|
||||
await render();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
expect(current.blocked).toBe(true);
|
||||
expect(current.errorCode).toBe('unconfirmed');
|
||||
expect(sessionStorage.getItem(key)).not.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps an acknowledged request frozen if the readback is stale or names the wrong policy', async () => {
|
||||
refresh.mockResolvedValue(summary);
|
||||
await render();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
const saved = sessionStorage.getItem(key);
|
||||
expect(current.blocked).toBe(true);
|
||||
refresh.mockResolvedValue({
|
||||
...changed,
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 1,
|
||||
});
|
||||
await act(async () => {
|
||||
await current.confirm();
|
||||
});
|
||||
expect(sessionStorage.getItem(key)).toBe(saved);
|
||||
refresh.mockResolvedValue({
|
||||
...changed,
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 2,
|
||||
});
|
||||
await act(async () => {
|
||||
await current.confirm();
|
||||
});
|
||||
expect(current.blocked).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores stale options from another Session and never changes its selection', async () => {
|
||||
const first = deferred<ManagedSessionApproval>();
|
||||
vi.mocked(provider.approvalChange!.queryOptions).mockImplementation((id) =>
|
||||
id === 's1'
|
||||
? first.promise
|
||||
: Promise.resolve({
|
||||
...options,
|
||||
availableApprovalModes: ['auto-edit'],
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
expect(current.optionsLoading).toBe(true);
|
||||
const staleSubmit = current.submit;
|
||||
await render({ ...summary, sessionId: 's2' });
|
||||
await act(async () => {
|
||||
first.resolve(options);
|
||||
});
|
||||
expect(current.options?.availableApprovalModes).toEqual(['auto-edit']);
|
||||
await act(async () => {
|
||||
await staleSubmit('auto-edit');
|
||||
});
|
||||
expect(provider.approvalChange!.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit when storage cannot retain the original operation', async () => {
|
||||
await render();
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('unavailable');
|
||||
});
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
expect(provider.approvalChange!.submit).not.toHaveBeenCalled();
|
||||
expect(current.errorCode).toBe('storage_unavailable');
|
||||
});
|
||||
|
||||
it('fails closed for missing capability, active Turns and unsupported options', async () => {
|
||||
await render({
|
||||
...summary,
|
||||
capabilities: { ...summary.capabilities, approvalModeChange: undefined },
|
||||
});
|
||||
expect(provider.approvalChange!.queryOptions).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
await render({ ...summary, phase: 'agent_running' });
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
vi.mocked(provider.approvalChange!.queryOptions).mockResolvedValue({
|
||||
...options,
|
||||
availableApprovalModes: ['default', 'yolo'],
|
||||
});
|
||||
await render();
|
||||
await act(async () => current.reloadOptions());
|
||||
await act(async () => {
|
||||
await current.submit('auto-edit');
|
||||
});
|
||||
vi.mocked(provider.approvalChange!.queryOptions).mockResolvedValue({
|
||||
...options,
|
||||
supported: false,
|
||||
});
|
||||
await act(async () => current.reloadOptions());
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
expect(provider.approvalChange!.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch or submit while hidden and can confirm a frozen receipt after capability withdrawal', async () => {
|
||||
await render(summary, false);
|
||||
expect(provider.approvalChange!.queryOptions).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
expect(provider.approvalChange!.submit).not.toHaveBeenCalled();
|
||||
sessionStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
sessionId: 's1',
|
||||
approvalMode: 'yolo',
|
||||
expectedRevision: 0,
|
||||
idempotencyKey: 'original',
|
||||
requestId: 'original-request',
|
||||
}),
|
||||
);
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(document.createElement('div'));
|
||||
await render({
|
||||
...summary,
|
||||
capabilities: { ...summary.capabilities, approvalModeChange: undefined },
|
||||
});
|
||||
await act(async () => {
|
||||
await current.confirm();
|
||||
});
|
||||
expect(provider.approvalChange!.submit).toHaveBeenCalledWith(
|
||||
's1',
|
||||
{ approvalMode: 'yolo', expectedRevision: 0 },
|
||||
expect.objectContaining({ idempotencyKey: 'original' }),
|
||||
);
|
||||
expect(current.pending).toBeUndefined();
|
||||
});
|
||||
|
||||
it('confirms the original receipt after another Turn becomes active without admitting a new selection', async () => {
|
||||
vi.mocked(provider.approvalChange!.submit).mockRejectedValueOnce(
|
||||
new TypeError('lost response'),
|
||||
);
|
||||
await render();
|
||||
await act(async () => {
|
||||
await current.submit('yolo');
|
||||
});
|
||||
const original = current.pending!;
|
||||
await render({
|
||||
...summary,
|
||||
phase: 'agent_running',
|
||||
capabilities: {
|
||||
...summary.capabilities,
|
||||
canSend: false,
|
||||
approvalModeChange: false,
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
await current.submit('default');
|
||||
await current.confirm();
|
||||
});
|
||||
expect(provider.approvalChange!.submit).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
vi.mocked(provider.approvalChange!.submit).mock.calls[1][2]
|
||||
.idempotencyKey,
|
||||
).toBe(original.idempotencyKey);
|
||||
expect(current.pending).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
||||
import type {
|
||||
ManagedAgentProvider,
|
||||
ManagedApprovalMode,
|
||||
ManagedAgentSessionSummary,
|
||||
ManagedSessionApproval,
|
||||
} from './managed-agent-provider';
|
||||
import { isManagedApprovalMode } from './managed-agent-provider';
|
||||
import { managedRequestId } from './managed-session-storage';
|
||||
|
||||
interface ApprovalIntent {
|
||||
sessionId: string;
|
||||
approvalMode: ManagedApprovalMode;
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
function readIntent(
|
||||
key: string,
|
||||
sessionId: string | undefined,
|
||||
): ApprovalIntent | undefined {
|
||||
try {
|
||||
const item: unknown = JSON.parse(sessionStorage.getItem(key) ?? 'null');
|
||||
if (!item || typeof item !== 'object') return undefined;
|
||||
const value = item as Record<string, unknown>;
|
||||
if (
|
||||
value['sessionId'] !== sessionId ||
|
||||
!isManagedApprovalMode(value['approvalMode']) ||
|
||||
typeof value['idempotencyKey'] !== 'string' ||
|
||||
!value['idempotencyKey'] ||
|
||||
typeof value['requestId'] !== 'string' ||
|
||||
!value['requestId'] ||
|
||||
!Number.isSafeInteger(value['expectedRevision']) ||
|
||||
Number(value['expectedRevision']) < 0
|
||||
)
|
||||
return undefined;
|
||||
return item as ApprovalIntent;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function persist(key: string, intent?: ApprovalIntent): boolean {
|
||||
try {
|
||||
if (intent) sessionStorage.setItem(key, JSON.stringify(intent));
|
||||
else sessionStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFINITIVE_ERRORS = new Set([
|
||||
'unsupported_approval_mode',
|
||||
'approval_selection_conflict',
|
||||
'turn_active',
|
||||
'session_context_busy',
|
||||
'session_operation_forbidden',
|
||||
'unsupported_feature',
|
||||
]);
|
||||
|
||||
export function useManagedApprovalChange(
|
||||
provider: ManagedAgentProvider,
|
||||
clientId: string,
|
||||
sessionId: string | undefined,
|
||||
summary: ManagedAgentSessionSummary | undefined,
|
||||
refreshSummary: (
|
||||
signal?: AbortSignal,
|
||||
) => Promise<ManagedAgentSessionSummary | undefined>,
|
||||
interactive: boolean,
|
||||
) {
|
||||
const key = `qwen-managed-approval:${provider.storageKey}:${sessionId ?? ''}`;
|
||||
const [state, setState] = useState<{
|
||||
key: string;
|
||||
pending?: ApprovalIntent;
|
||||
busy: boolean;
|
||||
errorCode?: string;
|
||||
}>({ key, pending: readIntent(key, sessionId), busy: false });
|
||||
const [optionsState, setOptionsState] = useState<{
|
||||
key: string;
|
||||
options?: ManagedSessionApproval;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}>({ key, loading: false, error: false });
|
||||
const [optionsRevision, setOptionsRevision] = useState(0);
|
||||
const pendingRef = useRef(state.pending);
|
||||
const running = useRef(false);
|
||||
const lifetime = useRef<AbortController | undefined>(undefined);
|
||||
const summaryRef = useRef(summary);
|
||||
summaryRef.current = summary?.sessionId === sessionId ? summary : undefined;
|
||||
const refreshRef = useRef(refreshSummary);
|
||||
refreshRef.current = refreshSummary;
|
||||
const optionsRef = useRef<ManagedSessionApproval | undefined>(undefined);
|
||||
const supported =
|
||||
summaryRef.current?.capabilities.approvalModeChange === true &&
|
||||
Boolean(provider.approvalChange);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
lifetime.current = abort;
|
||||
running.current = false;
|
||||
const pending = readIntent(key, sessionId);
|
||||
pendingRef.current = pending;
|
||||
setState({ key, pending, busy: false });
|
||||
return () => abort.abort();
|
||||
}, [key, provider, clientId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
optionsRef.current = undefined;
|
||||
setOptionsState({ key, loading: supported && interactive, error: false });
|
||||
if (supported && interactive && sessionId && provider.approvalChange) {
|
||||
void provider.approvalChange
|
||||
.queryOptions(sessionId, { clientId, signal: abort.signal })
|
||||
.then(async (options) => {
|
||||
if (abort.signal.aborted) return;
|
||||
optionsRef.current = options;
|
||||
setOptionsState({ key, options, loading: false, error: false });
|
||||
if (
|
||||
options.approvalRevision >
|
||||
(summaryRef.current?.approvalRevision ?? 0)
|
||||
) {
|
||||
try {
|
||||
await refreshRef.current(abort.signal);
|
||||
} catch {
|
||||
/* The summary poll retries. */
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!abort.signal.aborted)
|
||||
setOptionsState({ key, loading: false, error: true });
|
||||
});
|
||||
}
|
||||
return () => abort.abort();
|
||||
}, [
|
||||
key,
|
||||
provider,
|
||||
clientId,
|
||||
sessionId,
|
||||
supported,
|
||||
interactive,
|
||||
optionsRevision,
|
||||
summary?.approvalRevision,
|
||||
]);
|
||||
|
||||
async function run(intent: ApprovalIntent) {
|
||||
const owner = lifetime.current;
|
||||
const api = provider.approvalChange;
|
||||
if (
|
||||
!owner ||
|
||||
owner.signal.aborted ||
|
||||
running.current ||
|
||||
!interactive ||
|
||||
!api ||
|
||||
intent.sessionId !== sessionId ||
|
||||
summaryRef.current?.sessionId !== sessionId
|
||||
)
|
||||
return;
|
||||
running.current = true;
|
||||
setState((current) => ({ ...current, busy: true, errorCode: undefined }));
|
||||
const abort = new AbortController();
|
||||
const stop = () => abort.abort();
|
||||
owner.signal.addEventListener('abort', stop, { once: true });
|
||||
const timer = setTimeout(stop, 30_000);
|
||||
let accepted = false;
|
||||
const update = (pending?: ApprovalIntent, errorCode?: string) => {
|
||||
if (owner.signal.aborted) return;
|
||||
pendingRef.current = pending;
|
||||
const saved = persist(key, pending);
|
||||
setState((current) => ({
|
||||
...current,
|
||||
pending,
|
||||
errorCode: saved ? errorCode : 'storage_unavailable',
|
||||
}));
|
||||
};
|
||||
try {
|
||||
const result = await api.submit(
|
||||
intent.sessionId,
|
||||
{
|
||||
approvalMode: intent.approvalMode,
|
||||
expectedRevision: intent.expectedRevision,
|
||||
},
|
||||
{
|
||||
clientId,
|
||||
signal: abort.signal,
|
||||
idempotencyKey: intent.idempotencyKey,
|
||||
requestId: intent.requestId,
|
||||
},
|
||||
);
|
||||
accepted = true;
|
||||
if (owner.signal.aborted) return;
|
||||
if (
|
||||
result.sessionId !== intent.sessionId ||
|
||||
result.approvalMode !== intent.approvalMode ||
|
||||
!Number.isSafeInteger(result.approvalRevision) ||
|
||||
result.approvalRevision <= intent.expectedRevision
|
||||
)
|
||||
throw new Error('Approval change identity mismatch');
|
||||
const latest = await refreshRef.current(abort.signal);
|
||||
if (owner.signal.aborted) return;
|
||||
const revision = latest?.approvalRevision ?? 0;
|
||||
if (
|
||||
latest?.sessionId !== intent.sessionId ||
|
||||
revision < result.approvalRevision ||
|
||||
(revision === result.approvalRevision &&
|
||||
latest.approvalMode !== intent.approvalMode)
|
||||
)
|
||||
throw new Error('Approval selection is not confirmed');
|
||||
update();
|
||||
setOptionsRevision((value) => value + 1);
|
||||
return true;
|
||||
} catch (failure) {
|
||||
if (owner.signal.aborted) return;
|
||||
const definitive =
|
||||
!accepted &&
|
||||
failure instanceof JavaManagedAgentHttpError &&
|
||||
[400, 403, 409].includes(failure.status) &&
|
||||
DEFINITIVE_ERRORS.has(failure.code);
|
||||
if (definitive) {
|
||||
update(undefined, failure.code);
|
||||
setOptionsRevision((value) => value + 1);
|
||||
try {
|
||||
await refreshRef.current(abort.signal);
|
||||
} catch {
|
||||
/* The summary poll retries. */
|
||||
}
|
||||
} else update(intent, 'unconfirmed');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
owner.signal.removeEventListener('abort', stop);
|
||||
abort.abort();
|
||||
if (!owner.signal.aborted) {
|
||||
running.current = false;
|
||||
setState((current) => ({ ...current, busy: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(approvalMode: ManagedApprovalMode) {
|
||||
const current = summaryRef.current;
|
||||
const options = optionsRef.current;
|
||||
if (
|
||||
!lifetime.current ||
|
||||
lifetime.current.signal.aborted ||
|
||||
running.current ||
|
||||
pendingRef.current ||
|
||||
!interactive ||
|
||||
!current ||
|
||||
current.sessionId !== sessionId ||
|
||||
current.capabilities.approvalModeChange !== true ||
|
||||
!provider.approvalChange ||
|
||||
!options?.supported ||
|
||||
!options.availableApprovalModes.includes(approvalMode) ||
|
||||
!['created', 'completed', 'failed', 'cancelled'].includes(
|
||||
current.phase,
|
||||
) ||
|
||||
options.approvalRevision !== (current.approvalRevision ?? 0) ||
|
||||
approvalMode === current.approvalMode
|
||||
)
|
||||
return;
|
||||
const intent: ApprovalIntent = {
|
||||
sessionId: current.sessionId,
|
||||
approvalMode,
|
||||
expectedRevision: options.approvalRevision,
|
||||
idempotencyKey: managedRequestId(),
|
||||
requestId: managedRequestId(),
|
||||
};
|
||||
if (!persist(key, intent)) {
|
||||
setState((value) => ({ ...value, errorCode: 'storage_unavailable' }));
|
||||
return;
|
||||
}
|
||||
pendingRef.current = intent;
|
||||
setState((value) => ({ ...value, pending: intent }));
|
||||
return run(intent);
|
||||
}
|
||||
|
||||
const visible =
|
||||
state.key === key
|
||||
? state
|
||||
: { key, pending: readIntent(key, sessionId), busy: false };
|
||||
const optionView =
|
||||
optionsState.key === key
|
||||
? optionsState
|
||||
: { key, loading: supported, error: false };
|
||||
return {
|
||||
...visible,
|
||||
options: optionView.options,
|
||||
optionsLoading: optionView.loading,
|
||||
optionsError: optionView.error,
|
||||
supported,
|
||||
blocked: Boolean(visible.pending),
|
||||
submit,
|
||||
confirm: () =>
|
||||
pendingRef.current ? run(pendingRef.current) : Promise.resolve(undefined),
|
||||
reloadOptions: () => setOptionsRevision((value) => value + 1),
|
||||
isBlocked: () => Boolean(pendingRef.current) || running.current,
|
||||
};
|
||||
}
|
||||
@@ -54,12 +54,15 @@ describe('useManagedSession', () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
it('does not resurrect an older model selection or its cleared occupancy from a delayed summary', async () => {
|
||||
it('does not resurrect older model or approval selections from a delayed summary', async () => {
|
||||
const getSession = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: 'session-1',
|
||||
modelSelection: { modelId: 'A', revision: 1 },
|
||||
approvalMode: 'default',
|
||||
initialApprovalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
modelContext: {
|
||||
modelId: 'A',
|
||||
inputTokens: 200,
|
||||
@@ -70,10 +73,15 @@ describe('useManagedSession', () => {
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: 'session-1',
|
||||
modelSelection: { modelId: 'A', revision: 3 },
|
||||
approvalMode: 'yolo',
|
||||
initialApprovalMode: 'default',
|
||||
approvalRevision: 2,
|
||||
})
|
||||
.mockResolvedValue({
|
||||
sessionId: 'session-1',
|
||||
modelSelection: { modelId: 'A', revision: 1 },
|
||||
approvalMode: 'default',
|
||||
approvalRevision: 0,
|
||||
modelContext: {
|
||||
modelId: 'A',
|
||||
inputTokens: 200,
|
||||
@@ -103,6 +111,11 @@ describe('useManagedSession', () => {
|
||||
revision: 3,
|
||||
});
|
||||
expect(latest!.summary?.modelContext).toBeUndefined();
|
||||
expect(latest!.summary).toMatchObject({
|
||||
approvalMode: 'yolo',
|
||||
initialApprovalMode: 'default',
|
||||
approvalRevision: 2,
|
||||
});
|
||||
await act(async () => {
|
||||
await latest!.refreshSummary();
|
||||
});
|
||||
@@ -111,6 +124,11 @@ describe('useManagedSession', () => {
|
||||
revision: 3,
|
||||
});
|
||||
expect(latest!.summary?.modelContext).toBeUndefined();
|
||||
expect(latest!.summary).toMatchObject({
|
||||
approvalMode: 'yolo',
|
||||
initialApprovalMode: 'default',
|
||||
approvalRevision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
function deterministicBackoff(): () => void {
|
||||
|
||||
@@ -165,6 +165,17 @@ export function useManagedSession(
|
||||
modelContext: previous.modelContext,
|
||||
};
|
||||
}
|
||||
if (
|
||||
previous?.sessionId === incoming.sessionId &&
|
||||
(previous.approvalRevision ?? 0) > (incoming.approvalRevision ?? 0)
|
||||
) {
|
||||
next = {
|
||||
...next,
|
||||
approvalMode: previous.approvalMode,
|
||||
approvalRevision: previous.approvalRevision,
|
||||
initialApprovalMode: previous.initialApprovalMode,
|
||||
};
|
||||
}
|
||||
summaryRef.current = next;
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
@@ -252,6 +252,23 @@ const EN: Messages = {
|
||||
'managed.newWorkspaceTitle': 'New workspace',
|
||||
'managed.modelFixed':
|
||||
'The model is fixed for this task. Start a new task to choose another model.',
|
||||
'managed.approvalChange.loading': 'Loading approval policies…',
|
||||
'managed.approvalChange.optionsError': 'Could not load approval policies.',
|
||||
'managed.approvalChange.reload': 'Reload policies',
|
||||
'managed.approvalChange.nextTurn':
|
||||
'Approval policy changes apply to the next turn.',
|
||||
'managed.approvalChange.changing': 'Changing approval policy…',
|
||||
'managed.approvalChange.unconfirmed':
|
||||
'Approval change is not confirmed. Continue confirming the original request.',
|
||||
'managed.approvalChange.confirm': 'Continue confirming',
|
||||
'managed.approvalChange.busy':
|
||||
'Finish the current turn or recovery before changing approval policy.',
|
||||
'managed.approvalChange.storage':
|
||||
'Browser session storage is unavailable. The approval change was not sent.',
|
||||
'managed.approvalChange.conflict':
|
||||
'Approval policy changed elsewhere. Review the current selection.',
|
||||
'managed.approvalChange.failed':
|
||||
'Approval change was refused. Review the available policies.',
|
||||
'managed.model.loading': 'Loading available models…',
|
||||
'managed.model.optionsError': 'Could not load available models.',
|
||||
'managed.model.reload': 'Reload models',
|
||||
@@ -4613,6 +4630,20 @@ const ZH: Messages = {
|
||||
'managed.workspaceEmpty': '没有可读取的工作区。',
|
||||
'managed.newWorkspaceTitle': '新建工作区',
|
||||
'managed.modelFixed': '当前任务的模型已固定;如需更换,请新建任务。',
|
||||
'managed.approvalChange.loading': '正在加载审批策略…',
|
||||
'managed.approvalChange.optionsError': '加载审批策略失败。',
|
||||
'managed.approvalChange.reload': '重新加载策略',
|
||||
'managed.approvalChange.nextTurn': '审批策略变更从下一轮生效。',
|
||||
'managed.approvalChange.changing': '正在切换审批策略…',
|
||||
'managed.approvalChange.unconfirmed':
|
||||
'审批策略切换尚未确认,请继续确认原请求。',
|
||||
'managed.approvalChange.confirm': '继续确认',
|
||||
'managed.approvalChange.busy': '当前回合或恢复完成后才能切换审批策略。',
|
||||
'managed.approvalChange.storage':
|
||||
'浏览器会话存储不可用,审批策略变更未发送。',
|
||||
'managed.approvalChange.conflict':
|
||||
'审批策略已在其他页面修改,请检查当前选择。',
|
||||
'managed.approvalChange.failed': '审批策略切换被拒绝,请检查可用策略。',
|
||||
'managed.model.loading': '正在加载可用模型…',
|
||||
'managed.model.optionsError': '加载可用模型失败。',
|
||||
'managed.model.reload': '重新加载模型',
|
||||
|
||||
Reference in new issue
Block a user