feat(managed-agent): load pinned Skills from the composer (#14602)

This commit is contained in:
jinye authored and GitHub committed 2026-10-19 02:29:38 +00:00
1 parent 79362c2ecc
commit 0df419d898
27 files changed
+945 -50

No files matched your search

@@ -0,0 +1,102 @@
# Managed Session static Skills
[English](2026-10-19-managed-session-skills.md) | [简体中文](2026-10-19-managed-session-skills.zh-CN.md)
## Problem and current state
Managed AgentDefinitions already publish bounded static Skill catalogs. Each
Session pins its definition revision and digest, and the Hosted Harness loads
the published bodies through its `skill` tool. Managed Web Shell does not query
this catalog or enable the composer's Skills menu. A leading `/name` is currently
ordinary prompt text, and Plan mode excludes the instruction-only tool.
## Scope and ownership
Expose the existing Session catalog and connect selection to actual loading for
Workspace-bound Sessions, including local and SSH Workspaces. The control plane
reads immutable definition data; the Harness loads instructions; Workspace tools
retain their existing Runtime ownership and approval policy. No filesystem Skill
discovery, installation, dynamic activation, argument expansion, model override,
MCP configuration, or file-reference support is added.
## Proposed changes
### Session catalog
Add `POST /api/agent/web-shell/v1/session-skills/query`, accepting the existing
Session request shape and returning `skills` containing only `name` and
`description`. This is a persisted Session-scoped read with the `READER_ACTOR`
rule: require an actor, visible tenant-scoped Session, and current Workspace read
grant before accessing its pinned definition. Reuse `pinnedContent` and the
existing Skill validator, including digest validation. Never resolve the latest
AgentDefinition or contact a Runtime. Built-in agents and definitions without
Skills return an empty catalog. Snapshot read-only child profiles also return an
empty catalog because their Harness does not expose Skills. Missing or
inconsistent pins fail visibly.
Keep OpenAPI, generated Web Shell types, the surface registry, and admission
contract tests synchronized. There is no schema migration or new durable state.
### Composer
The Java provider exposes an optional catalog reader. A Session-scoped hook loads
on opening the Skills menu, caches successful immutable results, aborts on scope
changes, and suppresses late results from another Session. Reuse ChatEditor's
existing Skills submenu, loading, error, empty states, and `/name` insertion.
Reopening after a failed request retries. New Sessions without an assigned
Session ID do not query a guessed definition. Keep other Workspace capabilities
controlled by their existing interfaces.
### Explicit invocation and loading
Before the first model request, recognize consecutive leading `/name` tokens
that exactly match the pinned catalog. Load distinct selected Skills as a pure
`skill` batch through the existing Hosted tool executor. Preserve the user's
prompt and attachments, journal the synthetic assistant calls and tool results,
and supply those results to the model. A published body must therefore reach
model context even if the model would otherwise answer without calling tools.
Unmatched slash text retains its current meaning. The remaining prompt is task
input, not Skill arguments or an activation configuration.
Run normal Session and prompt Hooks before loading. Reuse PreToolUse,
PostToolUse, batch Hooks, refusal behavior, and inline limits. Loading never runs
scripts, widens permissions, or calls the Runtime's filesystem. Recovery uses
the committed calls and results through the existing Skill recovery path;
resumed model and tool continuations must not synthesize another activation.
Allow `skill` in the public Plan read set while preserving pure-Skill batching
and all write, Shell, and plan-approval restrictions.
## Affected areas
Managed Agent API/service/model records and contract tests; Web Shell generated
API, Java client/provider, Session composer, and a catalog hook; Hosted Skill
catalog, model loop, Plan policy, and focused tests. This extends the existing
[static Skill design](2026-10-15-managed-agent-extension-config.md).
## Verification and acceptance
1. A Session created at revision 1 lists and loads revision 1 after revision 2
changes the catalog; a new Session sees revision 2.
2. Reader access succeeds, absent actors and unauthorized/cross-tenant or deleted
Sessions fail before catalog access, and empty catalogs have an explicit UI
state. No body or host path is returned in the menu response.
3. Menu selection prepends the invocation. The first deterministic model request
contains the selected body's tool result, the original task, and attachments.
4. Explicit invocations obey Hooks, deduplicate selected names, and remain
bounded to the published catalog. Normal prompts and model-selected Skill
calls retain their behavior.
5. Plan mode permits static instruction loading and continues to refuse writes
and Shell. Restore/recovery does not replay a completed activation.
6. Session switching, provider changes, request errors, and late replies cannot
expose a previous Session's Skills.
The executable plan and results live in
`.qwen/e2e-tests/managed-session-skills.md`. Run focused Java and TypeScript
tests, API generation, Java verification, root build/typecheck/bundle, changed
file lint/format checks, and two clean full-diff audits. Report environment and
process-test limits separately from verified behavior.
## Open questions
None for this slice. Publishing and installing new Skills remain administration
tasks outside the composer.
@@ -0,0 +1,82 @@
# Managed Session 静态 Skills
[English](2026-10-19-managed-session-skills.md) | [简体中文](2026-10-19-managed-session-skills.zh-CN.md)
## 问题与当前状态
Managed AgentDefinition 已能发布有界静态 Skill 目录。每个 Session 固定定义
revision 和 digest,Hosted Harness 通过 `skill` 工具加载已发布正文。Managed Web
Shell 尚未查询该目录或启用输入框的 Skills 菜单。开头的 `/name` 当前只是普通
提示词文本,Plan 模式也排除了这个只加载指令的工具。
## 范围与归属
展示已有 Session 目录,并为绑定 Workspace 的 Session 接通选择与实际加载,覆盖
本机和 SSH Workspace。控制面读取不可变定义,Harness 加载指令,Workspace 工具
保留既有 Runtime 归属和审批策略。不增加文件系统 Skill 发现、安装、动态激活、
参数展开、模型覆盖、MCP 配置或文件引用能力。
## 方案
### Session 目录
增加 `POST /api/agent/web-shell/v1/session-skills/query`,接收已有 Session 请求
形状,返回 `skills`,每项只含 `name` 和 `description`。这是持久 Session 作用域
的读取,采用 `READER_ACTOR` 规则:先要求 actor、租户内可见 Session 和当前
Workspace 读取授权,再访问固定定义。复用 `pinnedContent` 和现有 Skill 校验,
包含 digest 校验。绝不查询最新 AgentDefinition,也不连接 Runtime。内置 Agent
和未发布 Skills 的定义返回空目录。快照只读子会话的 Harness 不提供 Skills,
因此也返回空目录。固定版本缺失或不一致时显式失败。
同步 OpenAPI、生成的 Web Shell 类型、接口注册表和准入契约测试。不迁移数据库,
不增加持久状态。
### 输入框
Java provider 提供可选目录读取能力。Session 作用域 Hook 在打开 Skills 菜单时
查询,缓存成功的不可变结果,作用域变化时取消请求,并忽略其他 Session 的迟到
响应。复用 ChatEditor 已有的 Skills 子菜单、加载、错误、空状态和 `/name` 插入。
请求失败后重新打开会重试。尚未分配 Session ID 的新会话不猜测定义进行查询。
其他 Workspace 能力继续由既有接口控制。
### 显式调用与加载
首次模型请求前识别连续位于开头、与固定目录精确匹配的 `/name` token。通过
现有 Hosted 工具执行器,将去重后的选择作为纯 `skill` 批次加载。保留用户提示词
和附件,将合成的 assistant 调用和工具结果写入日志,再把结果交给模型。因此,
即便模型原本会直接回答,也必须先在模型上下文中包含已发布正文。不匹配的 slash
文本保持现有含义。剩余提示词是任务输入,不是 Skill 参数或激活配置。
先执行正常的 Session 和提示词 Hook,再加载技能。复用 PreToolUse、PostToolUse、
批次 Hook、拒绝行为和内联大小限制。加载不执行脚本、不扩大权限,也不调用
Runtime 文件系统。恢复通过现有 Skill 恢复路径使用已提交的调用和结果;续行
模型或工具时不能再次合成激活。将 `skill` 加入公开 Plan 只读集合,同时保留
纯 Skill 批次以及写入、Shell 和计划审批限制。
## 涉及范围
Managed Agent API、服务、模型记录及契约测试;Web Shell 生成 API、Java client、
provider、Session 输入框和目录 Hook;Hosted Skill 目录、模型循环、Plan 策略
及定向测试。本方案扩展已有的[静态 Skill 设计](2026-10-15-managed-agent-extension-config.zh-CN.md)。
## 验证与验收
1. revision 2 修改目录后,revision 1 创建的 Session 仍列出并加载 revision 1;
新 Session 使用 revision 2。
2. reader 可以读取;缺少 actor、无授权、跨租户或已删除的 Session 在访问目录前
被拒绝;空目录有明确 UI 状态。菜单响应不包含正文或宿主路径。
3. 菜单选择前置调用文本。首次确定性模型请求包含选定正文的工具结果、原任务
和附件。
4. 显式调用遵守 Hook,按名称去重,并始终受已发布目录约束。普通提示词和模型
自主调用 Skill 保持原行为。
5. Plan 模式允许静态指令加载,继续拒绝写入和 Shell。恢复不会重放已完成激活。
6. 切换 Session、变更 provider、请求失败及迟到响应都不能显示上个 Session 的
Skills。
可执行计划和结果存放于 `.qwen/e2e-tests/managed-session-skills.md`。执行定向
Java 和 TypeScript 测试、API 生成、Java 验证、根目录 build/typecheck/bundle、
改动文件 lint/格式检查和两轮完整 diff 自审。区分环境及进程测试限制与已验证行为。
## 开放问题
本切片没有待定问题。发布和安装新 Skills 仍是输入框以外的管理任务。
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { LlmEventType, Turn } from '@qwen-code/qwen-code-core/core/turn.js';
import {
LlmEventType,
Turn,
type ToolCallRequestInfo,
} from '@qwen-code/qwen-code-core/core/turn.js';
import {
llmPreparedProviderPin,
type LlmPreparedRequest,
@@ -2054,3 +2058,87 @@ it('advertises only the published Skill catalog while keeping host Skill discove
);
expect(mocks.refreshSystemInstruction).toHaveBeenCalled();
});
it.each([undefined, 3])(
'loads explicit Skills before the first model request and preserves the task and attachments (initial round: %s)',
async (initialRound) => {
const mocks = config([{ type: LlmEventType.Finished }]);
const skills = new HostedSkillCatalog([
{ name: 'review', description: 'Review.', body: 'PINNED_REVIEW_BODY' },
{ name: 'testing', description: 'Test.', body: 'PINNED_TEST_BODY' },
]);
const setModelRound = vi.fn();
const execute = vi.fn(async (calls: ToolCallRequestInfo[]) => {
expect(setModelRound).toHaveBeenLastCalledWith(initialRound ?? 0);
return calls.map((call) => ({
functionResponse: {
id: call.callId,
name: call.name,
response: { output: skills.response(call.args) },
},
}));
});
const toolTurn = {
execute,
setModelRound,
consumeResults: vi.fn(async () => undefined),
declarations: vi.fn(async () => [skills.declaration()!]),
setPromptHookRunner: vi.fn(),
};
const prompt = '/review /testing /review inspect the patch';
const attachmentParts = [{ text: 'ATTACHMENT_CONTENT' }];
await runHostedHarnessTextTurn({
...input,
prompt,
initialRound,
attachmentParts,
skills,
toolTurn,
});
expect(execute).toHaveBeenCalledOnce();
expect(execute.mock.calls[0][0]).toMatchObject([
{ name: 'skill', args: { skill: 'review' }, isClientInitiated: true },
{ name: 'skill', args: { skill: 'testing' }, isClientInitiated: true },
]);
expect(mocks.setHistory).toHaveBeenLastCalledWith([
{ role: 'user', parts: [{ text: prompt }, ...attachmentParts] },
{ role: 'model', parts: expect.any(Array) },
]);
expect(JSON.stringify(mocks.requests[0])).toContain('PINNED_REVIEW_BODY');
expect(JSON.stringify(mocks.requests[0])).toContain('PINNED_TEST_BODY');
expect(mocks.sendOptions[0]).toMatchObject({
type: SendMessageType.ToolResult,
});
expect(toolTurn.consumeResults).toHaveBeenCalledOnce();
},
);
it('uses saved Skill results on continuation without loading the selection again', async () => {
const mocks = config([{ type: LlmEventType.Finished }]);
const result: Part[] = [
{
functionResponse: {
id: 'saved',
name: 'skill',
response: { output: 'SAVED_BODY' },
},
},
];
const toolTurn = {
execute: vi.fn(),
consumeResults: vi.fn(async () => undefined),
declarations: vi.fn(async () => []),
setPromptHookRunner: vi.fn(),
};
await runHostedHarnessTextTurn({
...input,
prompt: '/review inspect the patch',
skills: new HostedSkillCatalog([
{ name: 'review', description: 'Review.', body: 'NEW_BODY' },
]),
toolTurn,
resumeFromToolResults: result,
});
expect(toolTurn.execute).not.toHaveBeenCalled();
expect(mocks.requests[0]).toEqual(result);
});
+34 -2
View File
@@ -16,7 +16,7 @@ import type { HostedModelRequest } from './hosted-model-recovery.js';
import type { HostedModelSelection } from './hosted-model-selection.js';
import type { Content, Part } from '@google/genai';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
import { ToolNames } from '@qwen-code/qwen-code-core/tools/tool-names.js';
import type { ToolCallRequestInfo } from '@qwen-code/qwen-code-core/core/turn.js';
@@ -491,6 +491,38 @@ export async function runHostedHarnessTextTurn(input: {
let pendingToolResults =
input.resumeFromToolResults !== undefined ||
(input.resumeModel?.pendingToolResults ?? false);
const selectedSkills =
!input.resumeFromToolResults && !input.resumeModel && input.toolTurn
? (input.skills?.invocations(effectivePrompt) ?? [])
: [];
if (selectedSkills.length && input.toolTurn) {
const calls: ToolCallRequestInfo[] = selectedSkills.map((skill) => ({
callId: randomUUID(),
name: 'skill',
args: { skill },
isClientInitiated: true,
origin: { kind: 'client' },
prompt_id: input.promptId,
}));
const parts: Part[] = calls.map((call) => ({
functionCall: { id: call.callId, name: call.name, args: call.args },
}));
client
.getChat()
.setHistory([
...history,
{ role: 'user', parts: request },
{ role: 'model', parts },
]);
input.toolTurn.setModelRound?.(input.initialRound ?? 0);
request = await input.toolTurn.execute(
calls,
parts,
config.getModel(),
input.signal,
);
pendingToolResults = true;
}
let preparedRecovery = input.resumeModel?.prepared;
let stopHookActive =
(await input.hooks?.wasStopBlocked(input.promptId)) ?? false;
@@ -608,7 +640,7 @@ export async function runHostedHarnessTextTurn(input: {
)
: client.sendMessageStream(request, input.signal, input.promptId, {
type:
round === 0 && !input.resumeFromToolResults
round === 0 && !pendingToolResults
? SendMessageType.UserQuery
: SendMessageType.ToolResult,
// Published deltas cannot be un-glued after the fact: a cut that
+2 -2
View File
@@ -25,9 +25,9 @@ export const HOSTED_PUBLIC_PLAN_TOOL: FunctionDeclaration = {
},
};
export const HOSTED_PUBLIC_PLAN_READS = new Set(['read_file', 'glob']);
export const HOSTED_PUBLIC_PLAN_READS = new Set(['read_file', 'glob', 'skill']);
export const HOSTED_PUBLIC_PLAN_REFUSAL =
'Plan mode is read-only: only read_file, glob and a single exit_plan_mode call are allowed. Submit a plan with exit_plan_mode and wait for the user to approve it before making changes or running Shell commands.';
'Plan mode is read-only: only read_file, glob, static skill loading and a single exit_plan_mode call are allowed. Submit a plan with exit_plan_mode and wait for the user to approve it before making changes or running Shell commands.';
export async function hostedPublicPlanActive(
session: ManagedSession,
@@ -19,6 +19,20 @@ const skill = {
base_directory: '.qwen/skills/code-review',
};
it.each([
['/code-review inspect this', ['code-review']],
[' /code-review\n/code-review inspect this', ['code-review']],
['/code-reviewer inspect this', []],
['Explain /code-review', []],
['/unknown /code-review', []],
['/code-review/path', []],
])(
'recognizes only exact leading published invocations in %s',
(prompt, names) => {
expect(new HostedSkillCatalog([skill]).invocations(prompt)).toEqual(names);
},
);
it('advertises only published descriptions and loads a fixed Workspace-relative body', () => {
const source = [{ ...skill }];
const catalog = new HostedSkillCatalog(source);
@@ -125,6 +125,18 @@ export class HostedSkillCatalog {
return `The following published skills are available. Use the skill tool to load their instructions when relevant.\n${renderAvailableSkillsBlock([...this.skills])}`;
}
invocations(prompt: string): string[] {
const names = new Set<string>();
let remaining = prompt;
for (;;) {
const match = /^\s*\/([^\s/]+)(?=\s|$)/u.exec(remaining);
if (!match || !this.skills.some((skill) => skill.name === match[1]))
return [...names];
names.add(match[1]);
remaining = remaining.slice(match[0].length);
}
}
declaration(): FunctionDeclaration | undefined {
if (!this.skills.length) return undefined;
return {
@@ -75,6 +75,7 @@ import {
HostedSkillCatalog,
} from './hosted-skill-catalog.js';
import { answerResumedTurnCalls } from './hosted-runtime-recovery.js';
import * as hostedPlan from './hosted-plan-mode.js';
const broker = vi.hoisted(() => ({
capabilityDigest:
@@ -187,6 +188,7 @@ function createTurn(
approval?: { mode: HostedApprovalMode; timeoutMs?: number },
hooks?: HostedHookSession,
skills?: HostedSkillCatalog,
planMode: 'none' | 'public' | 'member' = 'none',
) {
return new HostedWorkspaceToolTurn(
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
@@ -206,7 +208,13 @@ function createTurn(
},
waiters,
},
{ hooks, skills },
{
hooks,
skills,
publicPlanMode: planMode === 'public',
memberPlanMode: planMode === 'member',
...(planMode === 'public' ? { profile: 'hosted-workspace-files/1' } : {}),
},
);
}
const calls = ['read_file', 'edit'].map((name, index) => ({
@@ -326,44 +334,55 @@ const publishedSkills = () =>
},
]);
it('loads a published static Skill durably without Runtime execution or approval', async () => {
const skills = publishedSkills();
turn = createTurn(false, { mode: 'default' }, undefined, skills);
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
const original: Part[] = [
{ functionCall: { id: call.callId, name: call.name, args: call.args } },
];
expect(await turn.declarations(new AbortController().signal)).toContainEqual(
skills.declaration(),
);
const responses = await turn.execute(
[call],
original,
'model',
new AbortController().signal,
);
expect(responses[0].functionResponse?.response?.['output']).toBe(
skills.response(call.args),
);
const history = await session.sink.project();
expect(history.map((entry) => entry.type)).toEqual([
'assistant',
'tool_result',
]);
expect(history[1].message?.parts).toEqual(responses);
expect(broker.acquire).not.toHaveBeenCalled();
expect(broker.prepare).not.toHaveBeenCalled();
expect(broker.execute).not.toHaveBeenCalled();
expect(
session.authority
.eventsInSequenceRange(1, session.authority.committedSequence)
.some((event) => event.kind === 'action.changed'),
).toBe(false);
await turn.finish();
const reloaded = createTurn(false, undefined, undefined, skills);
await reloaded.resumeCommittedResults(new AbortController().signal);
expect(broker.acquire).not.toHaveBeenCalled();
});
it.each(['none', 'public', 'member'] as const)(
'loads a published static Skill durably without Runtime execution or approval (Plan: %s)',
async (planMode) => {
if (planMode === 'public')
vi.spyOn(hostedPlan, 'hostedPublicPlanActive').mockResolvedValue(true);
const skills = publishedSkills();
turn = createTurn(false, { mode: 'default' }, undefined, skills, planMode);
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
const original: Part[] = [
{ functionCall: { id: call.callId, name: call.name, args: call.args } },
];
expect(
await turn.declarations(new AbortController().signal),
).toContainEqual(skills.declaration());
if (planMode === 'public')
expect(await turn.declarations(new AbortController().signal)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'write_file' }),
]),
);
const responses = await turn.execute(
[call],
original,
'model',
new AbortController().signal,
);
expect(responses[0].functionResponse?.response?.['output']).toBe(
skills.response(call.args),
);
const history = await session.sink.project();
expect(history.map((entry) => entry.type)).toEqual([
'assistant',
'tool_result',
]);
expect(history[1].message?.parts).toEqual(responses);
expect(broker.acquire).not.toHaveBeenCalled();
expect(broker.prepare).not.toHaveBeenCalled();
expect(broker.execute).not.toHaveBeenCalled();
expect(
session.authority
.eventsInSequenceRange(1, session.authority.committedSequence)
.some((event) => event.kind === 'action.changed'),
).toBe(false);
await turn.finish();
const reloaded = createTurn(false, undefined, undefined, skills);
await reloaded.resumeCommittedResults(new AbortController().signal);
expect(broker.acquire).not.toHaveBeenCalled();
},
);
it('refuses a mixed Skill and Workspace batch before approval Hooks or tool dispatch', async () => {
const fire = vi.fn<HostedHookSession['fire']>().mockResolvedValue(undefined);
@@ -691,6 +691,7 @@ export const HOSTED_INPUT_PREVIEW_TOOLS: readonly string[] = [
const HOSTED_PLAN_MODE_READS: ReadonlySet<string> = new Set([
'read_file',
'glob',
'skill',
'task_list',
'send_message',
]);
@@ -700,7 +701,7 @@ const HOSTED_PLAN_MODE_READS: ReadonlySet<string> = new Set([
* (decision 5): it says the run may only read and never names it.
*/
function hostedPlanModeRefusal(): string {
return `This run is in plan mode until the leader approves its plan: only read_file, glob, task_list${teamPlanAdmissionsEnabled() ? ', exit_plan_mode' : ''} and send_message to the leader are admitted. The decision arrives as your next instruction.`;
return `This run is in plan mode until the leader approves its plan: only read_file, glob, static skill loading, task_list${teamPlanAdmissionsEnabled() ? ', exit_plan_mode' : ''} and send_message to the leader are admitted. The decision arrives as your next instruction.`;
}
export const HOSTED_MEMBER_PLAN_TOOL: FunctionDeclaration = {
@@ -279,6 +279,10 @@ public final class ApiModels {
public record WebShellSessionRequest(@NotBlank String sessionId) {
}
public record WebShellSkill(String name, String description) { }
public record WebShellSessionSkills(List<WebShellSkill> skills) { }
public record WebShellTranscriptRequest(@NotBlank String sessionId,
String cursor, Integer limit) {
}
@@ -0,0 +1,27 @@
package com.alibaba.qwen.code.managedagent.api;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionRequest;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionSkills;
import com.alibaba.qwen.code.managedagent.service.ManagedSkillCatalogService;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebShellSkillsController {
private final ManagedSkillCatalogService skills;
public WebShellSkillsController(ManagedSkillCatalogService skills) {
this.skills = skills;
}
@PostMapping("/api/agent/web-shell/v1/session-skills/query")
public ResponseEntity<WebShellSessionSkills> query(TenantContext tenant,
@Valid @RequestBody WebShellSessionRequest request) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.body(skills.get(tenant.tenantId(), tenant.requireActorId(), request.sessionId()));
}
}
@@ -0,0 +1,33 @@
package com.alibaba.qwen.code.managedagent.service;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionSkills;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSkill;
import com.alibaba.qwen.code.managedagent.store.AgentDefinitionExtensions;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
import com.alibaba.qwen.code.managedagent.store.WorkspaceToolProfiles;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ManagedSkillCatalogService {
private final ManagedAgentService sessions;
private final ManagedAgentDefinitionStore definitions;
public ManagedSkillCatalogService(ManagedAgentService sessions, ManagedAgentDefinitionStore definitions) {
this.sessions = sessions;
this.definitions = definitions;
}
public WebShellSessionSkills get(String tenantId, String actorId, String sessionId) {
var session = sessions.requireReadableSession(tenantId, actorId, sessionId);
if (session.workspace() == null || session.agentDefinitionDigest() == null
|| WorkspaceToolProfiles.READ.equals(session.toolProfile())) {
return new WebShellSessionSkills(List.of());
}
var skills = AgentDefinitionExtensions.skills(definitions.pinnedContent(session).get("skills"), true);
return new WebShellSessionSkills(skills.stream()
.map(skill -> new WebShellSkill((String) skill.get("name"), (String) skill.get("description")))
.toList());
}
}
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "Qwen Managed Agent Public and WebShell API",
"version": "1.65.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.66.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": [
{
@@ -4301,6 +4301,54 @@
}
}
}
},
"/api/agent/web-shell/v1/session-skills/query": {
"post": {
"tags": ["WebShell"],
"operationId": "queryWebShellSessionSkills",
"x-qwen-implementation-status": "implemented",
"description": "Read only names and descriptions from the Session's pinned static Skill catalog. Requires a trusted actor and Workspace read access. Never resolves the latest AgentDefinition or discovers host Skills.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebShellSessionRequest"
}
}
}
},
"responses": {
"200": {
"description": "Session static Skill metadata.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebShellSessionSkills"
}
}
}
},
"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": {
@@ -11269,6 +11317,35 @@
}
},
"description": "Canonical base64 image with a matching signature. The complete canonical input containing attachments is limited to 48 KiB."
},
"WebShellSkill": {
"type": "object",
"additionalProperties": false,
"required": ["name", "description"],
"properties": {
"name": {
"type": "string",
"maxLength": 128
},
"description": {
"type": "string",
"maxLength": 4096
}
}
},
"WebShellSessionSkills": {
"type": "object",
"additionalProperties": false,
"required": ["skills"],
"properties": {
"skills": {
"type": "array",
"maxItems": 16,
"items": {
"$ref": "#/components/schemas/WebShellSkill"
}
}
}
}
}
}
@@ -263,6 +263,8 @@ class ManagedAgentApiContractTest {
entry(ApiModels.WebShellApprovalSelection.class, List.of("WebShellApprovalSelection")),
entry(ApiModels.WebShellSessionModelsRequest.class, List.of("WebShellSessionModelsRequest")),
entry(ApiModels.WebShellSessionModels.class, List.of("WebShellSessionModels")),
entry(ApiModels.WebShellSkill.class, List.of("WebShellSkill")),
entry(ApiModels.WebShellSessionSkills.class, List.of("WebShellSessionSkills")),
entry(ApiModels.WebShellChangeModelRequest.class, List.of("WebShellChangeModelRequest")),
entry(ApiModels.WebShellModelChangeResult.class, List.of("WebShellModelChangeResult")),
entry(WebShellSessionCapabilities.class,
@@ -1282,6 +1284,12 @@ class ManagedAgentApiContractTest {
exchange(drift, "queryWebShellSessionModels", 200,
post(WEB_SHELL + "/session-models/query").header(TENANT, workspaceTenant).principal(actor),
"{\"sessionId\":\"" + webBoundId + "\"}");
exchange(drift, "queryWebShellSessionSkills", 200,
post(WEB_SHELL + "/session-skills/query").header(TENANT, workspaceTenant).principal(actor),
"{\"sessionId\":\"" + webBoundId + "\"}");
exchange(drift, "queryWebShellSessionSkills", 401,
post(WEB_SHELL + "/session-skills/query").header(TENANT, workspaceTenant),
"{\"sessionId\":\"" + webBoundId + "\"}");
exchange(drift, "queryWebShellSessionModels", 401,
post(WEB_SHELL + "/session-models/query").header(TENANT, workspaceTenant),
"{\"sessionId\":\"" + webBoundId + "\"}");
@@ -485,6 +485,82 @@ class ManagedAgentDefinitionAdmissionTest {
.isInstanceOf(ApiException.class);
}
@Test
void listsOnlyMetadataFromTheSessionsPinnedSkillRevision() throws Exception {
String tenant = tenant();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
grant(tenant, "ws-a", "reader", false);
var content = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(FILES_DEFINITION);
content.putArray("skills").addObject().put("name", "review").put("description", "Original review")
.put("body", "PRIVATE_PINNED_BODY").put("base_directory", "docs");
String agentId = define(tenant, "definition", content.toString()).get("id").asText();
String first = mapper.readTree(createBound(tenant, agentId, null, "first")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
((com.fasterxml.jackson.databind.node.ObjectNode) content.at("/skills/0"))
.put("name", "testing").put("description", "New testing").put("body", "NEW_BODY");
define(tenant, "update", agentId, content.toString());
String second = mapper.readTree(createBound(tenant, agentId, null, "second")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
JsonNode original = mapper.readTree(querySkills(tenant, "reader", first)
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString());
assertThat(original).isEqualTo(mapper.readTree(
"{\"skills\":[{\"name\":\"review\",\"description\":\"Original review\"}]}"));
querySkills(tenant, "actor", second).andExpect(status().isOk())
.andExpect(jsonPath("$.skills[0].name").value("testing"));
querySkills(tenant, null, first).andExpect(status().isUnauthorized());
querySkills(tenant, "outsider", first).andExpect(status().isNotFound());
querySkills(tenant(), "actor", first).andExpect(status().isNotFound());
jdbc.update("DELETE FROM ma_workspace_access WHERE tenant_id = ? AND actor_id = ?", tenant,
"reader".getBytes(StandardCharsets.UTF_8));
querySkills(tenant, "reader", first).andExpect(status().isNotFound());
jdbc.update("UPDATE ma_session SET agent_definition_digest = ? WHERE tenant_id = ? AND session_id = ?",
"0".repeat(64), tenant, first);
querySkills(tenant, "actor", first).andExpect(status().isConflict())
.andExpect(jsonPath("$.error.code").value("hosted_agent_definition_conflict"));
jdbc.update("UPDATE ma_session SET status = 'DELETED' WHERE tenant_id = ? AND session_id = ?", tenant, second);
querySkills(tenant, "actor", second).andExpect(status().isNotFound());
}
@Test
void returnsAnEmptyCatalogForBuiltInAndSnapshotReadSessions() throws Exception {
String tenant = tenant();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
String builtin = mapper.readTree(createBound(tenant, "qwen-code", null, "builtin-skills")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
querySkills(tenant, "actor", builtin).andExpect(status().isOk())
.andExpect(jsonPath("$.skills").isEmpty());
String agentId = define(tenant, "empty-definition", FILES_DEFINITION).get("id").asText();
String custom = mapper.readTree(createBound(tenant, agentId, null, "custom-skills")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
querySkills(tenant, "actor", custom).andExpect(status().isOk())
.andExpect(jsonPath("$.skills").isEmpty());
var content = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(FILES_DEFINITION);
content.putArray("skills").addObject().put("name", "review").put("description", "Review")
.put("body", "SNAPSHOT_SKILL_BODY");
define(tenant, "snapshot-definition", agentId, content.toString());
String snapshot = mapper.readTree(createBound(tenant, agentId, null, "snapshot-skills")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
querySkills(tenant, "actor", snapshot).andExpect(status().isOk())
.andExpect(jsonPath("$.skills[0].name").value("review"));
jdbc.update("UPDATE ma_session SET tool_profile = 'hosted-workspace-read/1'"
+ " WHERE tenant_id = ? AND session_id = ?", tenant, snapshot);
querySkills(tenant, "actor", snapshot).andExpect(status().isOk())
.andExpect(jsonPath("$.skills").isEmpty());
}
private org.springframework.test.web.servlet.ResultActions querySkills(String tenant, String actorId,
String sessionId) throws Exception {
var request = post("/api/agent/web-shell/v1/session-skills/query")
.header(TenantContextFilter.HEADER, tenant).contentType(MediaType.APPLICATION_JSON)
.content("{\"sessionId\":\"" + sessionId + "\"}");
if (actorId != null) {
request.principal(actor(tenant, actorId));
}
return mvc.perform(request);
}
private JsonNode define(String tenant, String key, String definition)
throws Exception {
return define(tenant, key, null, definition);
@@ -1613,7 +1613,7 @@ class SurfaceAdmissionAcceptanceTest {
case WORKSPACE_CREATE -> "{\"displayName\":\"New workspace\",\"idempotencyKey\":\"create-key\"}";
case WORKSPACE_DIRECTORIES -> "{\"workspaceId\":\"workspace\",\"cwdRelative\":\".\"}";
case SESSION_OPTIONS -> "{\"agentId\":\"qwen-code\"}";
case SESSION_APPROVAL, SESSION_MODELS -> "{\"sessionId\":\"" + session + "\"}";
case SESSION_APPROVAL, SESSION_MODELS, SESSION_SKILLS -> "{\"sessionId\":\"" + session + "\"}";
case SESSION_APPROVAL_CHANGE -> "{\"sessionId\":\"" + session
+ "\",\"idempotencyKey\":\"approval-change\",\"approvalMode\":\"yolo\",\"expectedRevision\":0}";
case SESSION_MODEL_CHANGE -> "{\"sessionId\":\"" + session
@@ -309,6 +309,10 @@ public enum SurfaceRegistry {
"/api/agent/web-shell/v1/session-models/query",
Surface.WEBSHELL, RuleClass.READER_ACTOR,
EnumSet.of(Capability.SESSION_MODELS)),
WEBSHELL_SESSION_SKILLS(Method.POST,
"/api/agent/web-shell/v1/session-skills/query",
Surface.WEBSHELL, RuleClass.READER_ACTOR,
EnumSet.of(Capability.SESSION_SKILLS)),
WEBSHELL_SESSION_MODEL_CHANGE(Method.POST,
"/api/agent/web-shell/v1/session-models/change",
Surface.WEBSHELL, RuleClass.OPERATOR,
@@ -567,6 +571,7 @@ public enum SurfaceRegistry {
SESSION_APPROVAL,
SESSION_APPROVAL_CHANGE,
SESSION_MODELS,
SESSION_SKILLS,
SESSION_MODEL_CHANGE,
WORKSPACE_LIST,
WORKSPACE_GET,
@@ -285,6 +285,23 @@ describe('ManagedSessionsPage', () => {
});
}
it('connects the Session Skill catalog to the composer without enabling other Workspace features', async () => {
const skills = [{ name: 'review', description: 'Pinned review.' }];
const list = vi.fn().mockResolvedValue({ skills });
provider = { ...provider, skills: { list } };
await render('s1');
expect(mocks.editorProps?.visibleToolbarActions).toContain('addMenu');
expect(mocks.editorProps?.workspaceFeaturesEnabled).toBe(false);
expect(list).not.toHaveBeenCalled();
await act(async () => mocks.editorProps?.onSkillsOpenChange?.(true));
expect(list).toHaveBeenCalledWith(
's1',
expect.objectContaining({ clientId: expect.any(String) }),
);
expect(mocks.editorProps?.skills).toEqual(skills);
expect(mocks.editorProps?.skillsLoaded).toBe(true);
});
it.each(['default', 'auto-edit', 'yolo'] as const)(
'shows persisted policy as fixed even on an unbound Session (%s)',
async (approvalMode) => {
@@ -34,6 +34,7 @@ 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 { useManagedSkills } from './use-managed-skills';
import { ManagedSessionCwdControl } from './ManagedSessionCwdControl';
import { toManagedPermissionRequest } from './managed-approval';
import { isNonRetryableClientError } from './managed-request-error';
@@ -165,6 +166,7 @@ function ManagedSessionsContent({
[provider.storageKey],
);
const detail = useManagedSession(provider, clientId, sessionId);
const skills = useManagedSkills(provider, clientId, sessionId);
const pageRef = useRef<HTMLDivElement>(null);
const taskTriggerRef = useRef<HTMLButtonElement>(null);
const [wideTasks, setWideTasks] = useState(false);
@@ -1164,6 +1166,13 @@ function ManagedSessionsContent({
return false;
}}
commands={[]}
skills={skills.skills}
onSkillsOpenChange={
skills.supported ? skills.onOpenChange : undefined
}
skillsLoading={skills.loading}
skillsLoadError={skills.error}
skillsLoaded={skills.loaded}
workspaceFeaturesEnabled={false}
attachmentsEnabled={
summary?.capabilities.inputAttachments === true
@@ -1172,7 +1181,8 @@ function ManagedSessionsContent({
interactive
? [
...(summary?.capabilities.inputAttachments ||
summary?.planModeSupported
summary?.planModeSupported ||
skills.supported
? ['addMenu' as const]
: []),
...(summary?.planModeSupported || summary?.planMode
@@ -601,6 +601,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/agent/web-shell/v1/session-skills/query": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Read only names and descriptions from the Session's pinned static Skill catalog. Requires a trusted actor and Workspace read access. Never resolves the latest AgentDefinition or discovers host Skills. */
post: operations["queryWebShellSessionSkills"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1526,6 +1543,13 @@ export interface components {
mime_type: "image/png" | "image/jpeg" | "image/webp";
data: string;
};
WebShellSkill: {
name: string;
description: string;
};
WebShellSessionSkills: {
skills: components["schemas"]["WebShellSkill"][];
};
};
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. */
@@ -2803,4 +2827,34 @@ export interface operations {
409: components["responses"]["Conflict"];
};
};
queryWebShellSessionSkills: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["WebShellSessionRequest"];
};
};
responses: {
/** @description Session static Skill metadata. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WebShellSessionSkills"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
413: components["responses"]["PayloadTooLarge"];
};
};
}
@@ -317,6 +317,14 @@ export class JavaManagedAgentClient {
);
}
getSessionSkills(sessionId: string, signal?: AbortSignal) {
return this.post<Schemas['WebShellSessionSkills']>(
'/session-skills/query',
{ sessionId },
signal,
);
}
changeModel(
request: Schemas['WebShellChangeModelRequest'],
signal?: AbortSignal,
@@ -10,6 +10,37 @@ function jsonResponse(value: unknown): Response {
}
describe('createJavaManagedAgentProvider', () => {
it('reads the selected Session Skill catalog with authenticated cancellable requests', async () => {
const catalog = {
skills: [{ name: 'review', description: 'Published review.' }],
};
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(jsonResponse(catalog));
const provider = createJavaManagedAgentProvider({
baseUrl: 'https://product.example',
fetch: fetchImpl,
getHeaders: () => ({ authorization: 'Bearer scoped' }),
});
const abort = new AbortController();
expect(
await provider.skills!.list('session-a', {
clientId: 'client',
signal: abort.signal,
}),
).toEqual(catalog);
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe(
'https://product.example/api/agent/web-shell/v1/session-skills/query',
);
expect(init?.method).toBe('POST');
expect(init?.signal).toBe(abort.signal);
expect(init?.credentials).toBe('include');
expect(new Headers(init?.headers).get('authorization')).toBe(
'Bearer scoped',
);
expect(JSON.parse(String(init?.body))).toEqual({ sessionId: 'session-a' });
});
it('preserves Workspace registration keys and validates directory and operation identities', async () => {
const operation = {
operationId: 'registration-a',
@@ -147,6 +147,10 @@ export function createJavaManagedAgentProvider(
storageKey: storageKey(options),
canCancel: true,
acceptsWorkspaceCwd: false,
skills: {
list: (sessionId, request) =>
client.getSessionSkills(sessionId, request.signal),
},
tasks: {
list: (sessionId, request) =>
client.listTasks(
@@ -21,6 +21,8 @@ export type ManagedAgentRuntimeState =
export type ManagedApprovalMode = 'default' | 'auto-edit' | 'yolo';
export type ManagedInputBlock = components['schemas']['InputBlock'];
export type ManagedSessionSkills =
components['schemas']['WebShellSessionSkills'];
export function isManagedApprovalMode(
value: unknown,
@@ -228,6 +230,12 @@ export interface ManagedAgentProvider {
readonly storageKey: string;
readonly canCancel: boolean;
readonly acceptsWorkspaceCwd: boolean;
readonly skills?: {
list(
sessionId: string,
options: ManagedAgentRequestOptions,
): Promise<ManagedSessionSkills>;
};
/** Present when the provider can serve Hosted permission Actions. */
readonly actions?: {
listPending(
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type {
ManagedAgentProvider,
ManagedSessionSkills,
} from './managed-agent-provider';
import { useManagedSkills } from './use-managed-skills';
let root: Root;
let provider: ManagedAgentProvider;
let sessionId: string | undefined;
let current: ReturnType<typeof useManagedSkills>;
const catalog = {
skills: [{ name: 'review', description: 'Published review.' }],
};
function Probe() {
current = useManagedSkills(provider, 'client', sessionId);
return null;
}
const render = () => act(async () => root.render(<Probe />));
beforeEach(() => {
root = createRoot(document.createElement('div'));
sessionId = 'session-a';
provider = {
storageKey: 'tenant:actor',
skills: { list: vi.fn().mockResolvedValue(catalog) },
} as unknown as ManagedAgentProvider;
});
afterEach(async () => {
await act(async () => root.unmount());
});
it('lazily loads and caches a Session catalog including an empty catalog', async () => {
await render();
expect(provider.skills!.list).not.toHaveBeenCalled();
await act(async () => current.onOpenChange(true));
expect(current.skills).toEqual(catalog.skills);
expect(current.loaded).toBe(true);
await act(async () => current.onOpenChange(false));
await act(async () => current.onOpenChange(true));
expect(provider.skills!.list).toHaveBeenCalledOnce();
vi.mocked(provider.skills!.list).mockResolvedValue({ skills: [] });
sessionId = 'session-b';
await render();
expect(current.skills).toEqual([]);
expect(current.loaded).toBe(true);
});
it('shows failure and retries when reopened', async () => {
vi.mocked(provider.skills!.list).mockRejectedValueOnce(new Error('offline'));
await render();
await act(async () => current.onOpenChange(true));
expect(current.error).toBe(true);
expect(current.loading).toBe(false);
await act(async () => current.onOpenChange(false));
await act(async () => current.onOpenChange(true));
expect(current.error).toBe(false);
expect(current.skills).toEqual(catalog.skills);
});
it('aborts old requests and hides late results across Session and provider changes', async () => {
let release!: (value: ManagedSessionSkills) => void;
vi.mocked(provider.skills!.list).mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
await render();
await act(async () => current.onOpenChange(true));
expect(current.loading).toBe(true);
const signal = vi.mocked(provider.skills!.list).mock.calls[0][1].signal!;
sessionId = 'session-b';
await render();
expect(signal.aborted).toBe(true);
await act(async () =>
release({ skills: [{ name: 'old', description: 'Old.' }] }),
);
expect(current.skills).toEqual(catalog.skills);
provider = { ...provider, skills: undefined };
await render();
expect(current.supported).toBe(false);
expect(current.skills).toEqual([]);
sessionId = undefined;
await render();
expect(current.supported).toBe(false);
});
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import type {
ManagedAgentProvider,
ManagedSessionSkills,
} from './managed-agent-provider';
export function useManagedSkills(
provider: ManagedAgentProvider,
clientId: string,
sessionId: string | undefined,
) {
const [open, setOpen] = useState(false);
const [state, setState] = useState<{
provider: ManagedAgentProvider;
sessionId: string;
skills?: ManagedSessionSkills['skills'];
error: boolean;
}>();
const current =
state?.provider === provider && state.sessionId === sessionId
? state
: undefined;
const loaded = current?.skills !== undefined;
const supported = Boolean(provider.skills && sessionId);
useEffect(() => {
if (!open || loaded || !sessionId || !provider.skills) return;
const abort = new AbortController();
setState({ provider, sessionId, error: false });
void provider.skills
.list(sessionId, { clientId, signal: abort.signal })
.then(({ skills }) => {
if (!abort.signal.aborted)
setState({ provider, sessionId, skills, error: false });
})
.catch(() => {
if (!abort.signal.aborted)
setState({ provider, sessionId, error: true });
});
return () => abort.abort();
}, [provider, clientId, sessionId, open, loaded]);
return {
supported,
skills: current?.skills ?? [],
loading: supported && open && !loaded && !current?.error,
loaded,
error: current?.error === true,
onOpenChange: setOpen,
};
}
@@ -8,6 +8,8 @@ for (const width of [1280, 390]) {
const requests: Record<string, unknown>[] = [];
let summary: JavaAgentSession | undefined;
let laterInputs = 0;
let skillQueries = 0;
let skillInput: unknown;
let loseResponse = width === 390;
const workspaces = ['a', 'b'].map((id) => ({
workspaceId: `ws-${id}`,
@@ -86,7 +88,33 @@ for (const width of [1280, 390]) {
});
if (path.endsWith('/events/stream'))
return route.fulfill({ contentType: 'text/event-stream', body: '' });
if (path.endsWith('/turns/submit')) laterInputs++;
if (path.endsWith('/session-skills/query')) {
expect(body).toEqual({ sessionId: 'managed-ui-session' });
skillQueries++;
return route.fulfill({
json: {
skills: [
{
name: 'repository-review',
description: 'Review using the pinned guide.',
},
],
},
});
}
if (path.endsWith('/turns/submit')) {
laterInputs++;
skillInput = body.input;
return route.fulfill({
status: 202,
json: {
sessionId: summary!.sessionId,
turnId: 'skill-turn',
status: 'accepted',
replayed: false,
},
});
}
return route.fulfill({
status: 500,
json: { error: `Unexpected request: ${path}` },
@@ -142,6 +170,19 @@ for (const width of [1280, 390]) {
expect(second).toEqual(first);
}
expect(laterInputs).toBe(0);
expect(skillQueries).toBe(0);
await editor.fill('inspect the patch');
await page.getByTestId('composer-add-menu-trigger').click();
await page.getByTestId('composer-add-menu-skills').click();
await page.getByTestId('composer-add-menu-skills-item').click();
await expect(editor).toHaveText('/repository-review inspect the patch');
expect(skillQueries).toBe(1);
await editor.press('Enter');
await expect
.poll(() => skillInput)
.toEqual([
{ type: 'input_text', text: '/repository-review inspect the patch' },
]);
expect(errors).toEqual([]);
expect(
await page.evaluate(