Files
LocalAI/core/services/studio/executionobservation_test.go
T
Ettore Di Giacinto 9fafea8fdf fix(chats): show the network switcher and say why execution status is unknown
The Network row of the Tools menu could never show the Off, Registries and
All switcher: the host stopped setting `applies` because isolation alone does
not prove network-policy enforcement, and nothing else set it.

Let the provider report enforcement in its typed execution status
(`networkPolicyEnforced`). The host sets `applies` only when that report is
true and the status is available and isolated. The Sandbox reports it for the
gVisor and KVM backends, whose guests reach the network only through the proxy
that asks the host. Providers that do not report it keep the "not enforced"
text.

When the host cannot read the status, give the unknown state a reason
(`unknown_reason`) in the API and the Tools menu: owner unreachable, not
reported, stale, invalid, not selected, no answer or reported unknown. Log the
class of each failure, rate limited. After a failure that a restart of the
add-on owner can cause, let a long turn bind and read again, at most once every
five seconds, instead of keeping the first failure for the whole turn.

The Sandbox now reports "unavailable" with a reason when it started without
settings, and explains a backend it does not know.

When the Sandbox document skills are hidden because the Sandbox is not known to
be isolated, say so in the skill list, in the turn note and in the
`load_skill` error, instead of "This chat has no skills now".

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

303 lines
13 KiB
Go

// SPDX-License-Identifier: MIT
package studio
import (
"context"
"encoding/json"
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"sync/atomic"
"time"
"github.com/mudler/LocalAI/core/services/studio/executionrequest"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/session/sessiontest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
)
type observationProvider struct {
AddonTools
reads atomic.Int32
requests chan *executionrequest.Request
started chan struct{}
release chan struct{}
fail bool
}
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"}
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) {
p.reads.Add(1)
if p.started != nil {
close(p.started)
}
if p.release != nil {
select {
case <-p.release:
case <-ctx.Done():
return model.InvalidPayload(), ctx.Err()
}
}
if p.fail {
return model.InvalidPayload(), errors.New("failed")
}
return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
}}, nil)
if p.requests != nil {
p.requests <- r
}
return r, b.Addon, func() ExecutionDetail { return ExecutionDetail{} }
}
var _ = Describe("execution status product scope", func() {
It("shares one status observation across the actual session hook", func() {
p := &observationProvider{requests: make(chan *executionrequest.Request, 3)}
s := &Service{d: Deps{AddonTools: p}}
h := &sessiontest.FakeHarness{Turns: [][]sessiontest.Step{{{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}}}}
failures := make(chan string, 8)
observe := func(ctx context.Context) {
v, _ := ExecutionObservation(ctx, "alice", "renamed-provider")
if v.Isolation != model.Isolated {
failures <- "not isolated"
}
}
m, err := session.New(session.Options{Store: sessiontest.NewMemoryStore(), StateDir: GinkgoT().TempDir(), Harness: h, ProductScope: s.productScope,
ChatTools: func(ctx context.Context, _ session.Access) (session.TurnTools, error) {
observe(ctx)
tools := []session.Tool{}
for _, name := range []string{"status", "egress"} {
tools = append(tools, session.Tool{Name: name, Description: name, Schema: json.RawMessage(`{"type":"object"}`), Run: func(ctx context.Context, _ json.RawMessage) (string, error) { observe(ctx); return "ok", nil }})
}
return session.TurnTools{Tools: tools}, nil
}})
Expect(err).NotTo(HaveOccurred())
defer func() { Expect(m.Close(context.Background())).To(Succeed()) }()
x, err := session.NewAccess(workspace.Actor{Principal: "alice", ChatID: "c1"}, workspace.View{})
Expect(err).NotTo(HaveOccurred())
chat, err := m.Open(context.Background(), x, "c1")
Expect(err).NotTo(HaveOccurred())
Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed())
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case e := <-chat.Events():
if e.Kind == session.KindTurnEnd {
goto finished
}
case <-timer.C:
Fail("turn timed out")
}
}
finished:
Expect(failures).To(BeEmpty())
Expect(p.reads.Load()).To(Equal(int32(2)), "priming + one turn")
for range 2 {
r := <-p.requests
Expect(r.Observe(context.Background()) != model.Unknown()).To(BeFalse(), "scope not closed")
}
})
})
var _ = Describe("execution status product scope", func() {
It("closes status observations on cancellation and failure", func() {
for _, fail := range []bool{false, true} {
p := &observationProvider{started: make(chan struct{}), release: make(chan struct{}), fail: fail}
s := &Service{d: Deps{AddonTools: p}}
ctx, closeScope := s.executionScope(context.Background(), "alice", "c1")
consumer, cancel := context.WithCancel(ctx)
done := make(chan model.Value, 1)
go func() { v, _ := ExecutionObservation(consumer, "alice", "renamed-provider"); done <- v }()
select {
case <-p.started:
case <-time.After(5 * time.Second):
Fail("no read")
}
cancel()
Expect(<-done).To(Equal(model.Unknown()))
if fail {
close(p.release)
} else {
closeScope()
}
v, _ := ExecutionObservation(ctx, "alice", "renamed-provider")
Expect(v != model.Unknown()).To(BeFalse(), v)
closeScope()
Expect(p.reads.Load() != 1).To(BeFalse(), "retried read")
legacy, _ := ExecutionObservation(ctx, "alice", "legacy-provider")
Expect(legacy).To(Equal(model.Unknown()))
}
})
})
var _ = Describe("execution status product scope", func() {
It("retains the admitted status presentation binding per request", func() {
p := &observationProvider{}
s := &Service{d: Deps{AddonTools: p}}
ctx, closeScope := s.executionScope(context.Background(), "alice", "chat-one")
defer closeScope()
v := executionStatusView(ctx, "alice", "renamed-provider")
Expect(v.Readiness != "available" || v.Isolation != "isolated" || v.Generation != "1" || v.Incarnation != "one").To(BeFalse(), "missing or unredacted admitted presentation binding", v)
Expect(*executionStatusView(ctx, "alice", "renamed-provider")).To(Equal(*v))
Expect(executionStatusView(ctx, "bob", "renamed-provider").Readiness).To(Equal("unknown"))
next, closeNext := s.executionScope(context.Background(), "alice", "chat-two")
defer closeNext()
Expect(executionStatusView(next, "alice", "renamed-provider").Readiness != "available").To(BeFalse(), "fresh request lost status")
Expect(p.reads.Load() != 2).To(BeFalse(), "DTO re-probed within request")
})
})
var _ = Describe("execution status product scope", func() {
It("leaves unbound status presentation unknown", func() {
s := &Service{}
ctx, done := s.executionScope(context.Background(), "alice", "chat")
defer done()
v := executionStatusView(ctx, "alice", "")
Expect(v.Readiness != "unknown" || v.Isolation != "unknown" || v.Generation != "" || v.Incarnation != "").To(BeFalse(), "unbound status manufactured identity", v)
})
})
// reasonProvider returns a fixed observation, or none with a failure.
type reasonProvider struct {
AddonTools
request *executionrequest.Request
addon string
detail func() ExecutionDetail
}
func (p reasonProvider) NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() ExecutionDetail) {
return p.request, p.addon, p.detail
}
var _ = Describe("why an execution status is unknown", func() {
view := func(p reasonProvider, addon string) *ExecutionStatusView {
s := &Service{d: Deps{AddonTools: p}}
ctx, done := s.executionScope(context.Background(), "alice", "chat")
DeferCleanup(done)
return executionStatusView(ctx, "alice", addon)
}
request := func(payload model.Payload, err error) *executionrequest.Request {
b := model.Binding{Actor: "alice", Addon: "runner", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"}
r := executionrequest.New(context.Background(), 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 payload, err
}}, nil)
DeferCleanup(r.Close)
return r
}
It("carries the failure the provider recorded when it could not admit a request", func() {
for _, reason := range []UnknownReason{UnknownOwnerUnreachable, UnknownNotReported, UnknownStale} {
v := view(reasonProvider{detail: func() ExecutionDetail { return ExecutionDetail{Failure: reason} }}, "runner")
Expect(v.Readiness).To(Equal("unknown"))
Expect(v.UnknownReason).To(Equal(string(reason)))
}
})
It("says the status was not reported when the provider recorded nothing", func() {
Expect(view(reasonProvider{}, "runner").UnknownReason).To(Equal(string(UnknownNotReported)))
})
It("says the add-on does not run the chat's commands when another add-on was admitted", func() {
p := reasonProvider{request: request(model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil), addon: "runner", detail: func() ExecutionDetail { return ExecutionDetail{} }}
Expect(view(p, "other").UnknownReason).To(Equal(string(UnknownNotSelected)))
Expect(view(p, "").UnknownReason).To(Equal(string(UnknownNotSelected)))
})
It("carries the failure of a read the add-on did not answer", func() {
p := reasonProvider{request: request(model.InvalidPayload(), errors.New("boom")), addon: "runner",
detail: func() ExecutionDetail { return ExecutionDetail{Failure: UnknownInvalid} }}
Expect(view(p, "runner").UnknownReason).To(Equal(string(UnknownInvalid)))
})
It("says the add-on reported unknown when it answered unknown", func() {
p := reasonProvider{request: request(model.ValidPayload(model.Unknown()), nil), addon: "runner", detail: func() ExecutionDetail { return ExecutionDetail{} }}
Expect(view(p, "runner").UnknownReason).To(Equal(string(UnknownReportedUnknown)))
})
It("gives no reason once the status is known, and trusts enforcement only for available isolated", func() {
for value, want := range map[model.Value]bool{
{Readiness: model.Available, Isolation: model.Isolated}: true,
{Readiness: model.Available, Isolation: model.Host}: false,
} {
p := reasonProvider{request: request(model.ValidPayload(value), nil), addon: "runner",
detail: func() ExecutionDetail { return ExecutionDetail{NetworkPolicyEnforced: true, Failure: UnknownStale} }}
v := view(p, "runner")
Expect(v.UnknownReason).To(BeEmpty())
Expect(v.NetworkPolicyEnforced).To(Equal(want))
}
})
})
// flakyProvider fails to admit its first observations, then succeeds.
type flakyProvider struct {
AddonTools
failures int
failure UnknownReason
calls atomic.Int32
}
func (p *flakyProvider) NewExecutionObservation(ctx context.Context, user, _ string) (*executionrequest.Request, string, func() ExecutionDetail) {
if int(p.calls.Add(1)) <= p.failures {
return nil, "", func() ExecutionDetail { return ExecutionDetail{Failure: p.failure} }
}
b := model.Binding{Actor: user, Addon: "runner", Generation: "2", Incarnation: "two", ExecutionTool: "run", StatusTool: "inspect"}
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.Isolated}), nil
}}, nil)
return r, b.Addon, func() ExecutionDetail { return ExecutionDetail{} }
}
var _ = Describe("observing again after the add-on owner restarted", func() {
scopeOf := func(p ExecutionObserver) context.Context {
s := &Service{d: Deps{AddonTools: p.(AddonTools)}}
ctx, done := s.executionScope(context.Background(), "alice", "chat")
DeferCleanup(done)
return ctx
}
BeforeEach(func() {
old := observationRetryAfter
observationRetryAfter = 20 * time.Millisecond
DeferCleanup(func() { observationRetryAfter = old })
})
It("keeps the first answer inside the interval, then binds and reads again", func() {
p := &flakyProvider{failures: 1, failure: UnknownOwnerUnreachable}
ctx := scopeOf(p)
v, d := ObserveExecution(ctx, "alice", "runner")
Expect(v).To(Equal(model.Unknown()))
Expect(d.Failure).To(Equal(UnknownOwnerUnreachable))
_, _ = ObserveExecution(ctx, "alice", "runner")
Expect(p.calls.Load()).To(BeEquivalentTo(1), "no second admission within the interval")
time.Sleep(2 * observationRetryAfter)
v, d = ObserveExecution(ctx, "alice", "runner")
Expect(v.Isolation).To(Equal(model.Isolated))
Expect(d.Failure).To(BeEmpty())
Expect(p.calls.Load()).To(BeEquivalentTo(2))
_, _ = ObserveExecution(ctx, "alice", "runner")
Expect(p.calls.Load()).To(BeEquivalentTo(2), "a good status is not read again")
})
It("does not retry a refusal that waiting will not fix", func() {
p := &flakyProvider{failures: 1, failure: UnknownNotReported}
ctx := scopeOf(p)
_, _ = ObserveExecution(ctx, "alice", "runner")
time.Sleep(2 * observationRetryAfter)
v, d := ObserveExecution(ctx, "alice", "runner")
Expect(v).To(Equal(model.Unknown()))
Expect(d.Failure).To(Equal(UnknownNotReported))
Expect(p.calls.Load()).To(BeEquivalentTo(1))
})
It("does not start again once the scope is closed", func() {
p := &flakyProvider{failures: 1, failure: UnknownStale}
s := &Service{d: Deps{AddonTools: p}}
ctx, done := s.executionScope(context.Background(), "alice", "chat")
_, _ = ObserveExecution(ctx, "alice", "runner")
done()
time.Sleep(2 * observationRetryAfter)
v, _ := ObserveExecution(ctx, "alice", "runner")
Expect(v).To(Equal(model.Unknown()))
Expect(p.calls.Load()).To(BeEquivalentTo(1))
})
})