feat(studio): inspect subagent skill declarations

This commit is contained in:
xiexian committed 2026-10-10 11:33:12 +08:00
1 parent 309e6fb68d
commit 58ab25039e
5 files changed
+173

No files matched your search

@@ -111,6 +111,13 @@ export const EN_SETTINGS: Record<string, string> = {
"切换会保留对话并重建运行时,任务运行期间无法切换。标签只显示已探测到的能力;留空表示端点未声明,不代表不支持。":
"Switching keeps the conversation and rebuilds the runtime, and cannot be done while a task is running. Tags show only capabilities that could be detected; blank means the endpoint declared nothing, not that the feature is unsupported.",
"推理强度": "Reasoning effort",
"子代理声明": "Subagent declarations",
"{name} 的子代理声明": "Subagent declarations for {name}",
"声明的模型": "Declared model",
"声明的推理强度": "Declared reasoning effort",
"声明的工具": "Declared tools",
"显示技能自身声明;实际运行配置还会结合当前设置解析。":
"Shows the skill's declarations. Runtime configuration also depends on current settings.",
"以下档位由当前模型的端点支持,auto 表示使用端点自身的默认值。": "These levels are the ones the current model's endpoint accepts; auto leaves it at the endpoint's own default.",
"当前模型未提供可调的推理档位,因此不显示该选项。": "The current model exposes no adjustable reasoning level, so the option is not shown.",
"默认跟随模型容量;需要提前整理时再设置固定阈值。": "Follows model capacity by default; set a fixed threshold only when you need earlier compaction.",
+7
View File
@@ -3757,6 +3757,13 @@
}
.skrow .sc { flex: none; min-width: 44px; text-align: right; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.skrow .sw { margin-left: 4px; align-self: center }
.skrow:has(> .skprofile) { flex-wrap: wrap }
.skprofile { flex-basis: 100%; min-width: 0; font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted) }
.skprofile > p { margin: 8px 0 }
.skprofile > dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 12px; margin: 8px 0 0 }
.skprofile dt { color: var(--faint) }
.skprofile dd { margin: 0; overflow-wrap: anywhere; font-family: var(--mono) }
.skprofile code { display: block; font: inherit }
.seg {
display: inline-flex; flex: none; align-self: start; flex-wrap: wrap;
@@ -0,0 +1,20 @@
import { t } from "../i18n";
import type { SkillEntry } from "../port/port";
export function SkillDeclarations({ sk }: { sk: SkillEntry }) {
if (!sk.subagent || (!sk.model && !sk.effort && !sk.allowedTools?.length)) return null;
return (
<details className="skprofile">
<summary aria-label={t("{name} 的子代理声明", { name: sk.name })}>{t("子代理声明")}</summary>
<p>{t("显示技能自身声明;实际运行配置还会结合当前设置解析。")}</p>
<dl>
{sk.model && <><dt>{t("声明的模型")}</dt><dd>{sk.model}</dd></>}
{sk.effort && <><dt>{t("声明的推理强度")}</dt><dd>{sk.effort}</dd></>}
{!!sk.allowedTools?.length && <>
<dt>{t("声明的工具")}</dt>
<dd>{sk.allowedTools.map((name, index) => <code key={index}>{name}</code>)}</dd>
</>}
</dl>
</details>
);
}
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import type { AgentPort, SkillEntry } from "../port/port";
import { Exception } from "./CapabilityScope";
import { Switch } from "./Switch";
import { SkillDeclarations } from "./SkillDeclarations";
import { reason } from "../i18n/kernel";
// One skill, and the two things a reader wants from it: whether the model can
@@ -71,6 +72,7 @@ export function SkillRow({
</span>
{local && <Exception onClear={() => act(() => port.clearSkillOverride(sk.name, root || undefined))} busy={busy} />}
<Switch data-action="skill.enabled" data-target={sk.name} on={sk.enabled} busy={busy} label={t(sk.enabled ? "关闭 {name}" : "启用 {name}", { name: sk.name })} onClick={toggle} />
<SkillDeclarations sk={sk} />
</div>
);
}
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { boot, STORAGE } from "../i18n";
import type { AgentPort, SkillEntry } from "../port/port";
import { SkillRow } from "./SkillRow";
beforeEach(() => {
localStorage.setItem(STORAGE, "zh");
boot();
});
afterEach(cleanup);
const skill: SkillEntry = {
name: "review", slashName: "review", enabled: true, subagent: true,
model: "author/provider:model", effort: "high", allowedTools: ["read_file", "mcp__docs__lookup"],
};
function draw(over: Partial<SkillEntry> = {}) {
const port = { setSkillEnabled: vi.fn().mockResolvedValue(undefined), clearSkillOverride: vi.fn().mockResolvedValue(undefined) } as unknown as AgentPort;
const onDone = vi.fn();
const onFailed = vi.fn();
const row = (sk: SkillEntry, root = "/workspace/main", active = port) => <SkillRow
sk={sk} implicit port={active} root={root} onDone={onDone} onFailed={onFailed}
/>;
const view = render(row({ ...skill, ...over }));
return { ...view, port, onDone, onFailed, row };
}
function declaration() {
const summary = screen.getByText("子代理声明");
const details = summary.closest("details")!;
return { summary, details, body: within(details) };
}
it("lets the reader inspect literal profile declarations without running or changing the skill", async () => {
const { port, onDone, onFailed } = draw();
const { summary, details, body } = declaration();
expect(summary.getAttribute("aria-label")).toBe("review 的子代理声明");
expect(details.open).toBe(false);
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("switch"));
await userEvent.tab();
expect(document.activeElement).toBe(summary);
await userEvent.click(summary);
expect(details.open).toBe(true);
expect(body.getByText("声明的模型")).toBeTruthy();
expect(body.getByText("author/provider:model")).toBeTruthy();
expect(body.getByText("声明的推理强度")).toBeTruthy();
expect(body.getByText("high")).toBeTruthy();
expect(body.getByText("声明的工具")).toBeTruthy();
expect(body.getByText("read_file")).toBeTruthy();
expect(body.getByText("mcp__docs__lookup")).toBeTruthy();
expect(body.getByText("显示技能自身声明;实际运行配置还会结合当前设置解析。")).toBeTruthy();
await userEvent.click(summary);
expect(details.open).toBe(false);
expect(port.setSkillEnabled).not.toHaveBeenCalled();
expect(port.clearSkillOverride).not.toHaveBeenCalled();
expect(onDone).not.toHaveBeenCalled();
expect(onFailed).not.toHaveBeenCalled();
});
it.each<[string, Partial<SkillEntry>, string, string]>([
["model", { model: "custom", effort: undefined, allowedTools: undefined }, "声明的模型", "custom"],
["effort", { model: undefined, effort: "medium", allowedTools: [] }, "声明的推理强度", "medium"],
["tools", { model: undefined, effort: undefined, allowedTools: ["read_file"] }, "声明的工具", "read_file"],
])("shows only the supplied %s declaration", async (_, over, label, value) => {
draw(over);
const { summary, body } = declaration();
await userEvent.click(summary);
expect(body.getAllByRole("term").map((el) => el.textContent)).toEqual([label]);
expect(body.getByText(value)).toBeTruthy();
expect(body.queryByText("默认")).toBeNull();
});
it.each([
{ subagent: false },
{ model: undefined, effort: undefined, allowedTools: undefined },
{ model: "", effort: "", allowedTools: [] },
])("does not invent a subagent profile when it is absent (%j)", (over) => {
draw(over);
expect(screen.queryByText("子代理声明")).toBeNull();
expect(screen.queryByText("author/provider:model")).toBeNull();
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true");
});
it("keeps disabled declarations inspectable and the project toggle independent", async () => {
const { port, onDone } = draw({ enabled: false });
const { summary, details } = declaration();
await userEvent.click(summary);
expect(details.open).toBe(true);
await userEvent.click(screen.getByRole("switch", { name: "启用 review" }));
expect(port.setSkillEnabled).toHaveBeenCalledExactlyOnceWith("review", true, "project", "/workspace/main");
expect(onDone).toHaveBeenCalledTimes(1);
expect(details.open).toBe(true);
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
});
it("keeps clearing a project override independent of disclosure", async () => {
const { port, onDone } = draw({ switchScope: "project" });
const { details } = declaration();
await userEvent.click(screen.getByRole("button", { name: "仅本项目" }));
expect(port.clearSkillOverride).toHaveBeenCalledExactlyOnceWith("review", "/workspace/main");
expect(onDone).toHaveBeenCalledTimes(1);
expect(details.open).toBe(false);
});
it("reads refreshed declarations from the selected project and connection", async () => {
const { row, rerender, port } = draw();
await userEvent.click(declaration().summary);
const longTool = "mcp__" + "a".repeat(180) + "__lookup";
rerender(row({ ...skill, model: "next/model", effort: undefined, allowedTools: [longTool] }, "/workspace/other", { ...port }));
const { body } = declaration();
expect(body.queryByText("author/provider:model")).toBeNull();
expect(body.queryByText("high")).toBeNull();
expect(body.queryByText("read_file")).toBeNull();
expect(body.getByText("next/model")).toBeTruthy();
expect(body.getByText(longTool).textContent).toBe(longTool);
});
it("labels declarations in English while keeping author values literal", async () => {
localStorage.setItem(STORAGE, "en");
boot();
draw();
const summary = screen.getByText("Subagent declarations");
expect(summary.getAttribute("aria-label")).toBe("Subagent declarations for review");
await userEvent.click(summary);
const body = within(summary.closest("details")!);
expect(body.getByText("Declared model")).toBeTruthy();
expect(body.getByText("Declared reasoning effort")).toBeTruthy();
expect(body.getByText("Declared tools")).toBeTruthy();
expect(body.getByText("Shows the skill's declarations. Runtime configuration also depends on current settings.")).toBeTruthy();
expect(body.getByText("author/provider:model")).toBeTruthy();
expect(body.getByText("high")).toBeTruthy();
});