mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
Merge pull request #14152 from lancy69/fix/14126-minimal-image-capability
fix(composition): allow lightweight mode without image capability
This commit is contained in:
13 files changed
+238
-27
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import definitions from "../../../../internal/contract/agentcomposition/builtins.json";
|
||||
|
||||
export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks" | "commands";
|
||||
export interface CompositionModule { id: string; parameters?: Record<string, unknown> }
|
||||
export interface CompositionModule { id: string; parameters?: Record<string, unknown>; optional?: boolean }
|
||||
// A module the editor offers: the id is what a preset stores; the description is what the module
|
||||
// says about itself, for the person choosing, and is never saved.
|
||||
export interface CompositionChoice { id: string; description?: string; source?: "builtin" | "custom" }
|
||||
|
||||
@@ -50,3 +50,32 @@ describe("project composition authority", () => {
|
||||
await expect(presets.selectAgentComposition("user:gone")).rejects.toMatchObject({reason:{code:"agent_composition.unavailable",params:{module:"tools:no_such_tool"}}});
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional tool dependencies", () => {
|
||||
it("admits missing optional tools without relaxing required tool selection", async () => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"user:optional",tools:{mode:"select" as const,members:[{id:"bash"},{id:"missing_image",optional:true}]}};
|
||||
const saved = await presets.saveAgentPreset(definition,"");
|
||||
await expect(presets.selectAgentComposition(definition.id)).resolves.toMatchObject({id:definition.id});
|
||||
await presets.saveAgentPreset({...definition,tools:{...definition.tools,members:[...definition.tools.members,{id:"missing_required"}]}},saved.revision);
|
||||
await expect(presets.selectAgentComposition(definition.id)).rejects.toMatchObject({reason:{code:"agent_composition.unavailable",params:{module:"tools:missing_required"}}});
|
||||
});
|
||||
|
||||
it.each([undefined, false, true])("reports missing imported tools unless optional is true (%s)", async (optional) => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"user:import",tools:{mode:"select" as const,members:[{id:"missing_image",optional}]}};
|
||||
const preview = await presets.importAgentPreset(definition);
|
||||
if (optional) expect(preview.diagnostics).toEqual([]);
|
||||
else {
|
||||
expect(preview.diagnostics).toHaveLength(1);
|
||||
expect(preview.diagnostics[0]).toMatchObject({code:"agent_composition.unavailable",module:"tools:missing_image"});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["skills","mcp","extensions","hooks","commands"] as const)("rejects optional %s members on save and import", async (kind) => {
|
||||
const presets = new MockCompositions();
|
||||
const definition = {...builtinCompositions()[0],id:"user:import",[kind]:{mode:"select" as const,members:[{id:"missing_dependency",optional:true}]}};
|
||||
await expect(presets.saveAgentPreset(definition,"")).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
|
||||
await expect(presets.importAgentPreset(definition)).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}});
|
||||
});
|
||||
});
|
||||
@@ -38,12 +38,15 @@ export class MockCompositions {
|
||||
private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); }
|
||||
private requireModules(definition:AgentComposition) {
|
||||
for (const kind of ["tools"] as const) for (const member of definition[kind]?.members ?? []) {
|
||||
if (definition[kind]?.mode==="select" && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) {
|
||||
if (definition[kind]?.mode==="select" && !member.optional && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) {
|
||||
throw new HttpError(409,"",{code:"agent_composition.unavailable",params:{module:`${kind}:${member.id}`,detail:"Required module is unavailable or was revoked."}});
|
||||
}
|
||||
}
|
||||
}
|
||||
private validateAuthority(definition:AgentComposition) {
|
||||
private validateDefinition(definition:AgentComposition) {
|
||||
for (const kind of ["skills","mcp","extensions","hooks","commands"] as const) {
|
||||
if (definition[kind]?.members.some((member)=>member.optional)) this.fail(400,"invalid");
|
||||
}
|
||||
if (!definition.id.startsWith("project:")) return;
|
||||
for (const kind of ["mcp","extensions"] as const) for (const member of definition[kind]?.members ?? []) {
|
||||
if (Object.entries(member.parameters ?? {}).some(([key,value])=>["args","url","env","headers"].includes(key.toLowerCase()) && value!=null)) this.fail(400,"invalid");
|
||||
@@ -55,7 +58,7 @@ export class MockCompositions {
|
||||
const prior=this.catalog.presets.find((v)=>v.definition.id===definition.id);
|
||||
if ((prior?.revision ?? "")!==expectedRevision) this.fail(409,"conflict");
|
||||
if (!/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(definition.id) || !definition.name.trim()) this.fail(400,"invalid");
|
||||
this.validateAuthority(definition);
|
||||
this.validateDefinition(definition);
|
||||
const version={definition:structuredClone(definition),revision:`mock-${++this.library.revision}`};
|
||||
const items=definition.id.startsWith("project:") ? this.library.projects.get(this.workspace) ?? [] : this.library.user;
|
||||
const next=[...items.filter((v)=>v.definition.id!==definition.id),version];
|
||||
@@ -81,10 +84,10 @@ export class MockCompositions {
|
||||
}
|
||||
async importAgentPreset(content:AgentComposition) {
|
||||
if (content.schemaVersion!==1 || !content.name || !content.persona) this.fail(400,"invalid");
|
||||
this.validateAuthority(content);
|
||||
this.validateDefinition(content);
|
||||
const diagnostics:AgentPresetCatalog["diagnostics"]=[];
|
||||
for (const kind of ["tools","skills","mcp","extensions","hooks","commands"] as const) for (const member of content[kind]?.members ?? []) {
|
||||
if (!(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) diagnostics.push({code:"agent_composition.unavailable",module:`${kind}:${member.id}`,detail:"Dependency is not available on this host."});
|
||||
if (!(kind==="tools" && member.optional) && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) diagnostics.push({code:"agent_composition.unavailable",module:`${kind}:${member.id}`,detail:"Dependency is not available on this host."});
|
||||
}
|
||||
return {definition:structuredClone(content),diagnostics};
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ editable rows. Usage, details and copy do not change the new-task default.
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| Everyday partner (`builtin:general`) | Inherit current configuration for everyday code, file and research work. The stable ID preserves existing defaults. |
|
||||
| Lightweight partner (`builtin:minimal`) | A concise system persona, terminal (`bash`) and image reading (`view_image`), with native context compaction. Requires a vision-capable model. |
|
||||
| Lightweight partner (`builtin:minimal`) | A concise system persona and terminal (`bash`), with native context compaction. Adds image reading (`view_image`) when the selected model reads images and the tool is enabled. |
|
||||
|
||||
These display names replace Standard and Minimal; their IDs and capabilities
|
||||
stay unchanged. Retained conversations keep their saved names and revisions.
|
||||
The stable IDs preserve existing defaults. Retained conversations keep their
|
||||
saved names, revisions and module requirements.
|
||||
|
||||
The persona reference is DeepSeek Harness commit
|
||||
`687f282e8625c6b8d11ff58211a90524be2e4317`:
|
||||
@@ -99,8 +99,9 @@ It retains the host controls needed to answer and ask questions. Its terminal
|
||||
can still modify files and access the network under existing permissions and
|
||||
sandbox rules; Lightweight partner is not a read-only mode.
|
||||
|
||||
Missing image capability or
|
||||
disabled tools produce an explicit dependency error rather than another tool set.
|
||||
Missing required tools produce an explicit dependency error. Lightweight partner
|
||||
requires `bash`; its `view_image` member is optional, so a text-only model or a
|
||||
configuration that disables image reading can still use the terminal.
|
||||
|
||||
Code review and Research are no longer built-in catalog entries. Their existing
|
||||
conversations retain the original binding. An old default pointing to a retired
|
||||
@@ -110,6 +111,13 @@ Copy a built-in to customize it. Each module group either follows current author
|
||||
configuration, including resources enabled later, or uses only the selected IDs. An empty explicit
|
||||
selection disables the group.
|
||||
|
||||
A selected tool member may declare `"optional": true`, for example
|
||||
`{"id":"view_image","optional":true}`. It is admitted only if already available;
|
||||
the declaration does not enable a disabled tool or grant image capability.
|
||||
|
||||
Omitting `optional` keeps the member required. Other module groups do not accept
|
||||
optional members.
|
||||
|
||||
The editor lists locally available modules;
|
||||
imports preview missing dependencies without installing or launching them.
|
||||
|
||||
|
||||
@@ -51,10 +51,9 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
|
||||
| 模式 | 行为 |
|
||||
| --- | --- |
|
||||
| 日常搭档(`builtin:general`) | 创建会话时继承当前配置,处理日常代码、文件与资料任务;保留原稳定标识以兼容默认选择。 |
|
||||
| 轻装搭档(`builtin:minimal`) | 使用简短人设、终端(`bash`)和图片读取(`view_image`),保留原生上下文压缩,需要支持读图的模型。 |
|
||||
| 轻装搭档(`builtin:minimal`) | 使用简短人设和终端(`bash`),保留原生上下文压缩。所选模型支持读图且工具已启用时,额外提供图片读取(`view_image`)。 |
|
||||
|
||||
「日常搭档」「轻装搭档」分别替代原显示名称「标准模式」「精简模式」,ID 和能力
|
||||
保持不变。已有会话继续保留保存时的名称和版本。
|
||||
内置项的稳定 ID 继续兼容已有默认选择。已有会话保留保存时的名称、版本和模块要求。
|
||||
|
||||
人设提示参考 DeepSeek Harness 的提交 `687f282e8625c6b8d11ff58211a90524be2e4317`:
|
||||
[standard][harness-standard] 使用简短的编程 Agent 身份提示;
|
||||
@@ -70,7 +69,8 @@ Reasonix 已使用相同的 minimal 开场提示。
|
||||
|
||||
轻装搭档不装载自定义技能、命令、MCP、扩展、Hook 或子 Agent,保留回答和提问
|
||||
所需的宿主控制工具。终端仍能修改文件和访问网络,继续遵守已有权限与沙箱;
|
||||
轻装搭档不是只读模式。图片能力不可用或所选工具被禁用时会明确报告依赖错误。
|
||||
轻装搭档不是只读模式。`bash` 是必需工具,缺失时会报告依赖错误;`view_image`
|
||||
是可选工具,模型未启用读图能力或配置禁用该工具时,仍可使用终端。
|
||||
|
||||
代码审查、资料研究不再作为内置目录项提供;已有会话继续保留原绑定版本。
|
||||
如果旧默认指向已移除的内置项,目录会提示用户明确选择新的默认项,不自动替换。
|
||||
@@ -79,6 +79,11 @@ Reasonix 已使用相同的 minimal 开场提示。
|
||||
「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览
|
||||
缺失依赖,不会安装依赖或启动程序。
|
||||
|
||||
所选工具成员可声明 `"optional": true`,例如
|
||||
`{"id":"view_image","optional":true}`。只有当前已可用的工具才会加入,不会因此
|
||||
启用已禁用的工具或授予模型读图能力。不填写 `optional` 时仍为必需工具;
|
||||
其他模块类型不接受可选成员。
|
||||
|
||||
设置导航显示为「人设」。编辑页直接展示名称、用途、两段人设文本和可选运行时
|
||||
上下文开关;工具、技能与扩展按需展开。存放范围与自动生成的 ID 也直接展示,
|
||||
保存后 ID 与范围不可修改。
|
||||
|
||||
@@ -184,6 +184,9 @@ func verifySelected[T any](kind string, s agentcomposition.Selection, items []T,
|
||||
return nil
|
||||
}
|
||||
for _, m := range s.Members {
|
||||
if kind == "tools" && m.Optional {
|
||||
continue
|
||||
}
|
||||
if !slices.ContainsFunc(items, func(v T) bool { return id(v) == m.ID }) {
|
||||
return agentcomposition.Unavailable(kind+":"+m.ID, "Required module is unavailable or was revoked.")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,12 +25,16 @@ func TestEffectViewImageReachesOnlyAModelThatReadsImages(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
vision bool
|
||||
composition string
|
||||
enabled string
|
||||
missing string
|
||||
}{
|
||||
{true, ""}, {false, ""},
|
||||
{true, "builtin:general"}, {false, "builtin:general"},
|
||||
{true, "builtin:minimal"}, {false, "builtin:minimal"},
|
||||
{true, "", "", ""}, {false, "", "", ""},
|
||||
{true, "builtin:general", "", ""}, {false, "builtin:general", "", ""},
|
||||
{true, "builtin:minimal", "", ""}, {false, "builtin:minimal", "", ""},
|
||||
{true, "builtin:minimal", `["bash"]`, ""}, {false, "builtin:minimal", `["bash"]`, ""},
|
||||
{true, "builtin:minimal", `["view_image"]`, "tools:bash"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("vision=%v/composition=%s", tc.vision, tc.composition), func(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("vision=%v/composition=%s/enabled=%s", tc.vision, tc.composition, tc.enabled), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
search := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
@@ -37,6 +42,9 @@ func TestEffectViewImageReachesOnlyAModelThatReadsImages(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(search.Close)
|
||||
writeFile(t, home, "config.toml", fmt.Sprintf("[tools]\nweb_search_backends = [\"searxng\"]\nsearxng_url = %q\n", search.URL))
|
||||
if tc.enabled != "" {
|
||||
writeFile(t, home, "config.toml", fmt.Sprintf("[tools]\nenabled = %s\nweb_search_backends = [\"searxng\"]\nsearxng_url = %q\n", tc.enabled, search.URL))
|
||||
}
|
||||
dir := testenv.TempDir(t)
|
||||
rec := &peerToolProvider{}
|
||||
kind := fmt.Sprintf("boot-view-image-%s", strings.ReplaceAll(t.Name(), "/", "-"))
|
||||
@@ -55,9 +63,10 @@ vision = %v
|
||||
`, kind, tc.vision))
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard, AgentComposition: tc.composition})
|
||||
if tc.composition == "builtin:minimal" && !tc.vision {
|
||||
if !errors.Is(err, agentcomposition.ErrUnavailable) {
|
||||
t.Fatalf("text-only model must report unavailable image capability: %v", err)
|
||||
if tc.missing != "" {
|
||||
var diagnostic *agentcomposition.DiagnosticError
|
||||
if !errors.As(err, &diagnostic) || len(diagnostic.Diagnostics) != 1 || diagnostic.Diagnostics[0].Module != tc.missing {
|
||||
t.Fatalf("error = %v, want missing required module %s", err, tc.missing)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -68,11 +77,14 @@ vision = %v
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
_ = ctrl.Run(ctx, "hi")
|
||||
if !slices.Contains(rec.toolNames(), "bash") {
|
||||
t.Fatalf("required bash tool missing from provider request: %v", rec.toolNames())
|
||||
}
|
||||
has := false
|
||||
for _, name := range rec.toolNames() {
|
||||
has = has || name == "view_image"
|
||||
}
|
||||
want := tc.vision
|
||||
want := tc.vision && tc.enabled == ""
|
||||
if has != want {
|
||||
t.Fatalf("vision=%v composition=%s: tools in the first request = %v", tc.vision, tc.composition, rec.toolNames())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"builtin:general": "90d6a645648f1767371fedc2c01a487428dc847417c77bddd82a365520a65dfb",
|
||||
"builtin:minimal": "b8893eec828a9596efe7d1d12264d3124f0041f77d5d29520eecacd1d6d06cdf"
|
||||
"builtin:minimal": "b25962a98543abdb021332dcb2216d66a59ad90a3395c3d2f7d9cc30186a7a5b"
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
{
|
||||
"schemaVersion": 1, "id": "builtin:minimal", "name": "轻装搭档", "description": "带上终端和看图工具,轻装开工。和你一起处理小任务、快速试验,也方便对比模型表现。",
|
||||
"persona": {"prefix": "You are a helpful software engineer assistant.", "suffix": "", "include_runtime_context": false},
|
||||
"tools": {"mode":"select","members":[{"id":"bash"},{"id":"view_image"}]},
|
||||
"tools": {"mode":"select","members":[{"id":"bash"},{"id":"view_image","optional":true}]},
|
||||
"skills": {"mode":"select","members":[]}, "mcp": {"mode":"select","members":[]},
|
||||
"extensions": {"mode":"select","members":[]}, "hooks": {"mode":"select","members":[]}, "commands": {"mode":"select","members":[]}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type Persona struct {
|
||||
type Module struct {
|
||||
ID string `json:"id"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
type Selection struct {
|
||||
@@ -140,7 +141,7 @@ func (d Definition) Validate() error {
|
||||
if len(d.Persona.Prefix) > 32768 || len(d.Persona.Suffix) > 32768 {
|
||||
return fmt.Errorf("%w: persona", ErrInvalid)
|
||||
}
|
||||
for _, s := range []Selection{d.Tools, d.Skills, d.MCP, d.Extensions, d.Hooks, d.Commands} {
|
||||
for group, s := range []Selection{d.Tools, d.Skills, d.MCP, d.Extensions, d.Hooks, d.Commands} {
|
||||
if len(s.Members) > 10000 {
|
||||
return ErrInvalid
|
||||
}
|
||||
@@ -149,6 +150,9 @@ func (d Definition) Validate() error {
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range s.Members {
|
||||
if m.Optional && group != 0 {
|
||||
return fmt.Errorf("%w: optional is only supported for tools", ErrInvalid)
|
||||
}
|
||||
if m.ID == "" || len(m.ID) > 1024 || seen[m.ID] {
|
||||
return fmt.Errorf("%w: duplicate or empty module %q", ErrInvalid, m.ID)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agentcomposition
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
@@ -46,8 +47,116 @@ func TestMinimalTemplateHasNoAutomationOrDelegation(t *testing.T) {
|
||||
t.Fatalf("%s inherits modules", d.ID)
|
||||
}
|
||||
}
|
||||
if d.Tools.Mode != Select || len(d.Tools.Members) != 2 || d.Tools.Members[0].ID != "bash" || d.Tools.Members[1].ID != "view_image" {
|
||||
if d.Tools.Mode != Select || len(d.Tools.Members) != 2 || d.Tools.Members[0].ID != "bash" || d.Tools.Members[0].Optional || d.Tools.Members[1].ID != "view_image" || !d.Tools.Members[1].Optional {
|
||||
t.Fatalf("minimal tools = %+v", d.Tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalModulesDecodeAndValidate(t *testing.T) {
|
||||
for _, group := range []string{"tools", "skills", "mcp", "extensions", "hooks", "commands"} {
|
||||
t.Run(group, func(t *testing.T) {
|
||||
d := Builtins()[0]
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document[group] = json.RawMessage(`{"mode":"select","members":[{"id":"example","optional":true}]}`)
|
||||
data, err = json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := Decode(data)
|
||||
if group != "tools" {
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("optional %s decode error = %v", group, err)
|
||||
}
|
||||
if !errors.Is(decoded.Validate(), ErrInvalid) {
|
||||
t.Fatalf("optional %s passed structural validation", group)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decoded.Tools.Members[0].Optional {
|
||||
t.Fatal("optional tool lost during decode")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalToolsBindingRoundTrip(t *testing.T) {
|
||||
d := Builtins()[1]
|
||||
binding := Binding{
|
||||
Resolved: d,
|
||||
Version: Version{Definition: d, Revision: Revision(d)},
|
||||
}
|
||||
binding.Seal()
|
||||
restored := Clone(binding)
|
||||
if err := restored.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, definition := range []Definition{restored.Resolved, restored.Version.Definition} {
|
||||
if definition.Tools.Members[0].Optional || !definition.Tools.Members[1].Optional {
|
||||
t.Fatalf("tool requirements changed during binding roundtrip: %+v", definition.Tools)
|
||||
}
|
||||
}
|
||||
restored.Resolved.Tools.Members[1].Optional = false
|
||||
if !errors.Is(restored.Validate(), ErrInvalid) {
|
||||
t.Fatal("changing optional requirement did not invalidate binding digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsentOptionalPreservesRevisionAndBindingDigest(t *testing.T) {
|
||||
// A legacy declaration has no optional field; false must keep that encoding.
|
||||
d := Builtins()[0]
|
||||
d.Tools = Selection{Mode: Select, Members: []Module{{ID: "bash"}}}
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var legacy map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy["tools"] = json.RawMessage(`{"mode":"select","members":[{"id":"bash"}]}`)
|
||||
if Revision(d) != Digest(legacy) {
|
||||
t.Fatal("absent optional changed legacy revision")
|
||||
}
|
||||
decoded, err := Decode(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Tools.Members[0].Optional {
|
||||
t.Fatal("explicit tool became optional by default")
|
||||
}
|
||||
binding := Binding{Resolved: decoded, Version: Version{Definition: decoded, Revision: Revision(decoded)}}
|
||||
binding.Seal()
|
||||
var legacyBinding map[string]json.RawMessage
|
||||
data, err = json.Marshal(binding)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &legacyBinding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyData, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyBinding["resolved"] = legacyData
|
||||
versionData, err := json.Marshal(map[string]any{"definition": legacy, "revision": Revision(decoded)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyBinding["version"] = versionData
|
||||
legacyBinding["digest"] = json.RawMessage(`""`)
|
||||
if binding.Digest != Digest(legacyBinding) {
|
||||
t.Fatal("absent optional changed legacy binding digest")
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,9 @@ func (s *Server) importAgentPreset(w http.ResponseWriter, r *http.Request) {
|
||||
selections := map[string]agentcomposition.Selection{"tools": d.Tools, "skills": d.Skills, "mcp": d.MCP, "extensions": d.Extensions, "hooks": d.Hooks, "commands": d.Commands}
|
||||
for kind, selection := range selections {
|
||||
for _, m := range selection.Members {
|
||||
if m.Optional {
|
||||
continue
|
||||
}
|
||||
if !slices.ContainsFunc(catalog.Modules[kind], func(available agentpreset.Choice) bool { return available.ID == m.ID }) {
|
||||
diagnostics = append(diagnostics, agentcomposition.Diagnostic{Code: "agent_composition.unavailable", Module: kind + ":" + m.ID, Detail: "Dependency is not available on this host."})
|
||||
}
|
||||
|
||||
@@ -40,3 +40,38 @@ func TestAgentPresetRoutesKeepRevisionsAndRejectStaleWrites(t *testing.T) {
|
||||
t.Fatal("version missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentPresetImportOnlyReportsMissingRequiredTools(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(operatorHandler(s))
|
||||
defer srv.Close()
|
||||
for _, optional := range []bool{false, true} {
|
||||
d := agentcomposition.Builtins()[0]
|
||||
d.ID = "user:imported"
|
||||
d.Tools = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "unavailable-image-reader", Optional: optional}}}
|
||||
body, err := json.Marshal(map[string]any{"content": d})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := postProvider(t, srv.URL, "/agent-presets/import", string(body))
|
||||
var preview struct {
|
||||
Diagnostics []agentcomposition.Diagnostic `json:"diagnostics"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&preview)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("optional=%v: import status %d", optional, resp.StatusCode)
|
||||
}
|
||||
if optional {
|
||||
if len(preview.Diagnostics) != 0 {
|
||||
t.Fatalf("optional tool refused: %+v", preview.Diagnostics)
|
||||
}
|
||||
} else if len(preview.Diagnostics) != 1 || preview.Diagnostics[0].Code != "agent_composition.unavailable" || preview.Diagnostics[0].Module != "tools:unavailable-image-reader" {
|
||||
t.Fatalf("required tool diagnostic = %+v", preview.Diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user