Files
LocalAI/core/services/studio/capabilities_test.go
T
Ettore Di Giacinto d0ac371e01 fix(studio): do not report a draft chat as not selected
A chat that is not created yet has no add-on bound to it, so its
execution status cannot be read. The host now says so with the reason
draft, and the Home composer shows no commands warning for it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-03 11:31:37 +00:00

337 lines
16 KiB
Go

package studio_test
import (
"context"
"github.com/mudler/LocalAI/core/services/studio/executionrequest"
model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// sandboxExecution is how the Sandbox add-on declares its commands tool.
var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox", Note: sandbox.GuestToolsNote}
// runAddon is the Sandbox add-on as the broker lists it: bash runs commands.
func runAddon() []studio.AddonTool {
bash := addonTool("sandbox", "bash")
bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution
read := addonTool("sandbox", "read")
read.Action = true
return []studio.AddonTool{bash, read}
}
// isolatedAddons is a broker that says how its runner isolates commands.
type isolatedAddons struct {
*fakeAddons
mode string
}
func (i isolatedAddons) Isolation(context.Context, string, string) string { return i.mode }
// writeExecSkill writes a shared skill that needs a way to run commands.
func writeExecSkill(f *files.Service, name string) {
Expect(f.EnsureShared(context.Background())).To(Succeed())
body := "---\nname: " + name + "\ndescription: Work with " + name + " files.\nrequires: [exec]\n---\nRun python.\n"
_, err := f.Store().Put(context.Background(), workspace.Actor{Principal: files.AdminPrincipal}, "/skills/"+name+"/SKILL.md",
strings.NewReader(body), workspace.PutOptions{Overwrite: true})
Expect(err).NotTo(HaveOccurred())
}
// newCapsChat is a chat service with files and the given add-on broker.
func newCapsChat(broker studio.AddonTools, script func(workspace.Actor, string) []studiotest.ToolStep) (*studio.Service, *studiotest.ToolHarness, *files.Service) {
f := newFiles()
if script == nil {
script = func(workspace.Actor, string) []studiotest.ToolStep { return []studiotest.ToolStep{{Say: "ok"}} }
}
svc, h := newToolService(script, func(d *studio.Deps) {
d.Files = f
if broker != nil {
d.AddonTools = broker
}
})
return svc, h, f
}
func turnPrompt(svc *studio.Service, h *studiotest.ToolHarness, id string) string {
sent(svc, alice, id, "hello")
idle(svc, alice, id)
return h.TurnPrompt(id)
}
var _ = Describe("capabilities", func() {
It("tells a chat with no way to run commands what it can do instead", func() {
svc, h, f := newCapsChat(nil, nil)
writeExecSkill(f, "xlsx")
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: nothing in this chat runs code or commands."))
Expect(prompt).To(ContainSubstring("Never claim or imply that you ran anything"))
Expect(prompt).To(ContainSubstring("do not ask a sub-agent to run it"))
Expect(prompt).To(ContainSubstring("write the output directly with write_file"))
Expect(prompt).To(ContainSubstring("give the user the script to run"))
Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing an add-on that runs commands"))
Expect(prompt).To(ContainSubstring("A skill marked unavailable here needs this"))
Expect(prompt).To(ContainSubstring("- xlsx: Work with xlsx files. (unavailable here: needs command execution)"))
Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "an unavailable skill must not give the path to start it")
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsNone}))
})
It("keeps the block short and leaves out the skill line when no skill needs commands", func() {
svc, h, _ := newCapsChat(nil, nil)
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
var block string
for part := range strings.SplitSeq(prompt, "\n\n") {
if strings.HasPrefix(part, "Commands:") {
block = part
}
}
Expect(block).NotTo(BeEmpty(), "no Commands block in:\n%s", prompt)
Expect(block).NotTo(ContainSubstring("skill"))
// About 60 tokens: a word is more than one token on average, so count words.
Expect(len(strings.Fields(block))).To(BeNumerically("<=", 70), "the block is too long: %q", block)
})
It("names the tool that runs commands and lists the skills as usable", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, f := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "isolated"}, nil)
writeExecSkill(f, "xlsx")
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox)."))
Expect(prompt).To(ContainSubstring(sandbox.GuestToolsNote), "the isolated sandbox lists its programs")
Expect(prompt).To(ContainSubstring("Skill folders are not visible in the isolated environment: load skills with load_skill."))
Expect(prompt).NotTo(ContainSubstring("nothing in this chat runs code"))
Expect(prompt).To(ContainSubstring("\n- xlsx: "))
Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "the index names no path")
Expect(prompt).NotTo(ContainSubstring("unavailable here"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Note: sandbox.GuestToolsNote}))
})
It("warns when commands run on the server with no isolation", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "host"}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands directly on the server, with no isolation."))
Expect(turnPrompt(svc, h, id)).NotTo(ContainSubstring(sandbox.GuestToolsNote), "the host has its own programs, not the guest's")
Expect(turnPrompt(svc, h, id)).NotTo(ContainSubstring("Skill folders are not visible"), "only an isolated chat has the hint")
})
It("says the Sandbox is off when the chat switched it off", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(fa, nil)
id := create(svc, alice)
Expect(svc.SetAddonsOff(context.Background(), alice, id, []string{"sandbox"})).To(Succeed())
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("nothing in this chat runs code or commands"))
Expect(prompt).To(ContainSubstring("they can switch the Sandbox add-on on in the Tools menu of this chat"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox", Title: "Sandbox"}))
})
It("says the Sandbox cannot run when no backend works", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
gate := gatedAddons{fakeAddons: fa, reasons: map[string]string{"sandbox": "no sandbox backend works on this host"}}
svc, h, _ := newCapsChat(gate, nil)
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("the Sandbox add-on cannot run commands on this server"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox", Title: "Sandbox"}))
})
It("does not call the add-on off for a user who cannot use it", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(fa, nil)
a := create(svc, alice)
_ = turnPrompt(svc, h, a) // the service has now seen the Sandbox
b := create(svc, "bob")
Expect(svc.SetAddonsOff(context.Background(), "bob", b, []string{"sandbox"})).To(Succeed())
Expect(svc.CommandRunner(context.Background(), "bob", b)).To(Equal(studio.Commands{State: studio.CommandsNone}))
})
It("refuses a sub-agent task that asks to run something when nothing runs commands", func() {
svc, h, _ := newCapsChat(nil, func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"Actually execute the script /workspace/home/make_deck.py and report the output","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "ran it"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
res := h.ResultsFor("spawn_agent")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(MatchError(studio.ErrSubAgentCannotRun))
Expect(res[0].Err.Error()).To(ContainSubstring("the sub-agent has no way to run commands either"))
Expect(childrenOf(svc, alice, id)).To(BeEmpty(), "no sub-agent may start for a task it cannot do")
})
It("starts a sub-agent that asks to run something when the chat can run commands", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(fa, func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"Run python3 make_deck.py","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "built"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
res := h.ResultsFor("spawn_agent")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).NotTo(HaveOccurred())
Expect(res[0].Out).To(ContainSubstring("built"))
kids := childrenOf(svc, alice, id)
Expect(kids).To(HaveLen(1))
// A sub-agent that can run commands needs no commands block; it is told
// where its parent keeps working files.
Expect(h.TurnPrompt(kids[0].ID)).NotTo(ContainSubstring("Commands:"), "a sub-agent that can run commands needs no block")
Expect(h.TurnPrompt(kids[0].ID)).To(HavePrefix("Working files:"))
})
It("tells a sub-agent without a way to run commands not to pretend", func() {
svc, h, _ := newCapsChat(nil, func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"summarize the notes","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "summary"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
kids := childrenOf(svc, alice, id)
Expect(kids).To(HaveLen(1))
Expect(h.TurnPrompt(kids[0].ID)).To(ContainSubstring("Commands: nothing here runs code or commands."))
})
It("refuses the same sub-agent task again while no file of the chat changed", func() {
svc, h, _ := newCapsChat(nil, func(_ workspace.Actor, text string) []studiotest.ToolStep {
switch text {
case "ask":
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"Check the numbers in q1.csv","wait":true}`}, {Say: "asked"}}
case "ask again":
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"check the numbers in q1.csv","wait":true}`}, {Say: "asked"}}
case "write":
return []studiotest.ToolStep{{Tool: "write_file", Args: `{"path":"/q1.csv","content":"a,b\n1,2\n"}`}, {Say: "written"}}
}
return []studiotest.ToolStep{{Say: "the numbers add up"}}
})
id := create(svc, alice)
for _, msg := range []string{"ask", "ask again", "write", "ask again"} {
sent(svc, alice, id, msg)
idle(svc, alice, id)
}
res := h.ResultsFor("spawn_agent")
Expect(res).To(HaveLen(3))
Expect(res[0].Err).NotTo(HaveOccurred())
Expect(res[1].Err).To(MatchError(studio.ErrSubAgentRepeat), "the same task with no new file must be refused")
Expect(res[2].Err).NotTo(HaveOccurred(), "a file changed since the first run, so the task may run again")
Expect(childrenOf(svc, alice, id)).To(HaveLen(2))
})
})
// presentationAddons supplies an admitted request just as the product broker does.
type presentationAddons struct {
*fakeAddons
bound bool
}
func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() studio.ExecutionDetail) {
if !p.bound {
return nil, "", nil
}
b := model.Binding{Actor: user, Addon: "sandbox", Generation: "27", Incarnation: "runtime-two", ExecutionTool: "bash", StatusTool: "status"}
r := executionrequest.New(ctx, b, "q", model.Eligible, executionrequest.IO{
Validate: func(context.Context, model.Binding) error { return nil },
Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Host}), nil
},
}, nil)
Expect(r.Binding()).To(Equal(b))
return r, b.Addon, func() studio.ExecutionDetail { return studio.ExecutionDetail{Explanation: "plain explanation"} }
}
var _ = Describe("execution presentation DTO", func() {
It("returns admitted binding identity through CommandRunner and no identity without admission", func() {
for _, bound := range []bool{true, false} {
broker := presentationAddons{fakeAddons: &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, bound: bound}
svc, _, _ := newCapsChat(broker, nil)
id := create(svc, alice)
c, err := svc.CommandRunner(context.Background(), alice, id)
Expect(err).NotTo(HaveOccurred())
Expect(c.ExecutionStatus).NotTo(BeNil())
if bound {
Expect(c.ExecutionStatus.Generation).To(Equal("27"))
Expect(c.ExecutionStatus.Incarnation).To(Equal("runtime-two"))
Expect(c.ExecutionStatus.Readiness).To(Equal("available"))
} else {
Expect(c.ExecutionStatus.Generation).To(BeEmpty())
Expect(c.ExecutionStatus.Readiness).To(Equal("unknown"))
}
}
})
})
var _ = Describe("the execution status of a chat that does not exist yet", func() {
It("says the chat is a draft, not that the add-on is not selected", func() {
broker := presentationAddons{fakeAddons: &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, bound: true}
svc, _, _ := newCapsChat(broker, nil)
_, c, err := svc.DraftAddons(context.Background(), alice, "m", "")
Expect(err).NotTo(HaveOccurred())
Expect(c.ExecutionStatus).NotTo(BeNil())
Expect(c.ExecutionStatus.UnknownReason).To(Equal("draft"))
})
})
var _ = Describe("a second provider of commands", func() {
runbox := func() []studio.AddonTool {
bash := addonTool("runbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Execution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "Runbox has python3 and jq."}
return []studio.AddonTool{bash}
}
It("is described by the title and the note it declares, not by a name known to the host", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "isolated"}, nil)
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Runbox)."))
Expect(prompt).To(ContainSubstring("Runbox has python3 and jq."))
Expect(prompt).NotTo(ContainSubstring("Sandbox"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "runbox", Provider: "runbox", Title: "Runbox", Note: "Runbox has python3 and jq."}))
})
It("is named when the chat switched it off and when it cannot run", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}}
svc, h, _ := newCapsChat(fa, nil)
id := create(svc, alice)
Expect(svc.SetAddonsOff(context.Background(), alice, id, []string{"runbox"})).To(Succeed())
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("they can switch the Runbox add-on on in the Tools menu of this chat"))
gate := gatedAddons{fakeAddons: fa, reasons: map[string]string{"runbox": "no backend works"}}
svc, h, _ = newCapsChat(gate, nil)
id = create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("the Runbox add-on cannot run commands on this server"))
Expect(prompt).NotTo(ContainSubstring("Sandbox"))
})
It("behaves as an unnamed provider when its manifest declares no descriptor", func() {
bash := addonTool("runbox", "bash")
bash.Action, bash.Shadows = true, "bash"
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: {bash}}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: ""}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code."))
})
})