test(studio): load_skill must load a document skill under an isolated Sandbox

The spec builds the turn tools under the product scope and then calls
load_skill with a context of its own, as the harness does. It fails
with the not_selected text on master.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:31:36 +00:00
1 parent 095d621b03
commit 67a503c224
2 files changed
+84 -1

No files matched your search

@@ -2,6 +2,7 @@
package studio
import (
"cmp"
"context"
"encoding/json"
"errors"
@@ -24,10 +25,12 @@ type observationProvider struct {
started chan struct{}
release chan struct{}
fail bool
// addon is the add-on the observation is bound to; empty is renamed-provider.
addon string
}
func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() ExecutionDetail) {
b := model.Binding{Actor: user, Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"}
b := model.Binding{Actor: user, Addon: cmp.Or(p.addon, "renamed-provider"), Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"}
r := executionrequest.New(ctx, b, "host-q", model.Eligible, executionrequest.IO{
Validate: func(context.Context, model.Binding) error { return nil },
Read: func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) {
@@ -0,0 +1,80 @@
package studio
import (
"context"
"encoding/json"
"testing/fstest"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/session"
model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// observedBroker is a Sandbox whose isolation comes from the typed execution
// status, read through the request scope as the real gateway reads it.
type observedBroker struct {
*documentBroker
*observationProvider
}
func (b *observedBroker) Tools(ctx context.Context, u string) ([]AddonTool, error) {
return b.documentBroker.Tools(ctx, u)
}
func (b *observedBroker) Unavailable(ctx context.Context, u, a string, _ bool) string {
if v, e := ExecutionObservation(ctx, u, a); v.Readiness == model.Unavailable {
return e
}
return ""
}
func (b *observedBroker) Isolation(ctx context.Context, u, a string) string {
v, _ := ExecutionObservation(ctx, u, a)
if v.Readiness != model.Available || v.Isolation == model.UnknownIsolation {
return ""
}
return string(v.Isolation)
}
var _ = Describe("load_skill as the harness calls it", func() {
It("loads a document skill when the tool runs outside the turn's context values", func() {
ctx := context.Background()
b := &observedBroker{
documentBroker: &documentBroker{skillBroker: &skillBroker{}, defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}},
observationProvider: &observationProvider{addon: "sandbox"},
}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
agentsStore, err := agents.NewJSON("")
Expect(err).NotTo(HaveOccurred())
svc.d.Agents = agentsStore
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"pptx/SKILL.md": {Data: skillMD("pptx", "Sandbox slides.", "Slides body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Isolated: []string{"pptx"}, Running: true})
agent, err := svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Slides", Model: "m", Addons: []string{"sandbox"}, Skills: []string{"pptx"}})
Expect(err).NotTo(HaveOccurred())
parent, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
rec := session.ChatRecord{ID: "slides-child", Principal: "alice", Model: "m", Kind: session.KindSubAgent, ParentID: parent.ID, AgentID: agent.ID}
Expect(svc.d.Store.CreateChat(ctx, rec)).To(Succeed())
x, err := svc.ChatAccess(ctx, "alice", rec.ID)
Expect(err).NotTo(HaveOccurred())
// The turn builds its tools under the product scope, as the session does.
turnCtx, closeScope := svc.productScope(ctx, x)
defer closeScope()
turn, err := svc.chatTools(turnCtx, x)
Expect(err).NotTo(HaveOccurred())
var loader session.Tool
for _, tool := range turn.Tools {
if tool.Name == "load_skill" {
loader = tool
}
}
Expect(loader.Run).NotTo(BeNil(), "the tool is offered at the start of the turn")
// The harness calls the tool with a context of its own.
body, err := loader.Run(context.Background(), json.RawMessage(`{"name":"pptx"}`))
Expect(err).NotTo(HaveOccurred())
Expect(body).To(ContainSubstring("Slides body."))
})
})