Files
LocalAI/tests/addons/tables/tools_test.go
T
mudler-agentandEttore Di Giacinto 16acc8926b feat(addons): simplify Office and discover Tables (#13029)
* feat(tables): list accessible databases

Let agents discover existing databases through the host gateway.
Keep listing actor-scoped and separate from executable tools.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tables): verify database listing authority

Exercise model discovery through the real owner and carrier.
Check shared access, revocation, denial and resource limits.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(tables): bundle database search guidance

Teach agents to discover existing databases before querying records.
Package and verify the skill with the Tables add-on.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tables): cover packaged chat skill gates

Load the real receipt-verified Tables instructions through the temporary
Studio workspace. Check the index and load refusal after runtime, chat
switch and saved-agent grant changes.

This fixture starts at the receipt and broker boundary; packaging tests
cover artifact construction and integrity separately.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(office): parse Markdown and CSV inputs

Convert bounded text inputs into document and workbook structures.
Render supported Markdown formatting without fetching external content.
Preserve spreadsheet fields as strings rather than inferring formulas.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): preserve list markers, breaks and PDF page margins

Cover actual DOCX breaks and tabs, explicit zero list starts, empty list rejection, and PDF margin/pagination regressions.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): guard Markdown parsing work

Check cancellation and nesting during parser callbacks.
Validate table dimensions before padded table allocation.
Keep parser state isolated to each conversion.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(office): recognize tables before applying limits

Leave mismatched table-like paragraphs as ordinary Markdown.
Keep oversized real tables bounded before allocation.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(office): simplify document creation inputs

Accept Markdown for DOCX and PDF, and CSV for single-sheet XLSX files.
Replace nested creation schemas to reduce model tool grammar complexity.
Keep existing read, edit and PowerPoint behavior and internal office IDs.

Present the add-on as Simple Office and document the compatibility change.
Keep same-style Markdown fragments editable in generated DOCX files.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ci): align add-on fixtures and test inventories

Tables fixtures must carry the skill their manifest declares. Preserve
its bytes in package tests and mark only binaries executable in UI
fixtures. Assert that installation keeps the shipped skill intact.

Register the new Tables tests and the connector case from master in
exact suite inventories. Include the new database read in tool counts.

Run Office regressions through Ginkgo and check reader close errors.
Keep all document assertions and the existing CI gates intact.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: nib:gpt-6.1-sol

* fix(ci): restore Office and packaging checks

Keep the generated Office label consistent with Simple Office. Install
Poppler for the PDF layout regressions on Linux and macOS.

Separate successful runtime setup from the 300 ms deadline under test.
Keep the timeout, cancellation, stop and admission assertions intact.

Refresh the packaging inventory from all 209 executed cases, retaining
repeated platform checks and the Office and Tables skill checks.

Assisted-by: nib:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-02 12:48:08 +01:00

294 lines
13 KiB
Go

// SPDX-License-Identifier: MIT
package tables_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/core/services/addons/tools"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// callTool runs one model tool call as a user's API key, the credential of an
// unattended agent. The adapter is the real one over the real owner gateway.
func (h *host) callTool(user, name, body string) (tools.ToolResult, error) {
GinkgoHelper()
adapter := tools.NewToolAdapter(h.app.AddonGateway())
return adapter.Execute(context.Background(), h.actor(user), name, json.RawMessage(body))
}
func (h *host) pending(user string) []map[string]any {
GinkgoHelper()
got := h.send("GET", "/api/addons/proposals?state=pending", "", h.bearer(user))
Expect(got.code).To(Equal(200), "%v", got.body)
var out []map[string]any
list, _ := got.body["proposals"].([]any)
for _, x := range list {
out = append(out, x.(map[string]any))
}
return out
}
var _ = Describe("Tables tools under the real owner", Label("application"), func() {
It("offers the manifest tools to a permitted user and nothing to a user without the feature or after revocation", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
adapter := tools.NewToolAdapter(h.app.AddonGateway())
ctx := context.Background()
defs, e := adapter.Discover(ctx, h.actor("alice"))
Expect(e).NotTo(HaveOccurred())
Expect(defs).To(HaveLen(16))
Expect(defs).To(ContainElement(HaveField("Name", "addon__tables__databases-list")))
for _, d := range defs {
Expect(d.Name).To(HavePrefix("addon__tables__"))
}
defs, e = adapter.Discover(ctx, h.actor("bob"))
Expect(e).NotTo(HaveOccurred())
Expect(defs).To(HaveLen(16))
Expect(defs).To(ContainElement(HaveField("Name", "addon__tables__databases-list")))
// Another user's identity cannot be claimed by changing a field of
// the actor: the owner checks the stored credential.
forged := h.actor("bob")
forged.CredentialID = "key:" + h.keys["alice"]
_, e = adapter.Discover(ctx, forged)
Expect(e).To(HaveOccurred())
// Revocation of the feature withdraws every tool at once.
Expect(h.app.AuthDB().Where("user_id = ?", "bob").Delete(&auth.UserPermission{}).Error).To(Succeed())
_, e = adapter.Discover(ctx, h.actor("bob"))
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
_, e = h.callTool("bob", "addon__tables__databases-create", `{"name":"Nope"}`)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
})
It("applies a change a chat makes at once for the chat's user, leaves no proposal, and still refuses a key", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
chat := h.app.StudioAddonTools()
Expect(chat).NotTo(BeNil())
ctx := context.Background()
text, e := chat.Call(ctx, "alice", "conv1", "addon__tables__databases-create", json.RawMessage(`{"name":"Chat ledger"}`))
Expect(e).NotTo(HaveOccurred())
var made tools.ToolResult
Expect(json.Unmarshal([]byte(text), &made)).To(Succeed(), text)
Expect(made.Kind).To(Equal(tools.ResultApplied), text)
Expect(made.Applied.ResourceID).NotTo(BeEmpty())
// The add-on kept a change set for the creation, so the chat can offer an undo.
Expect(made.Applied.Undoable).To(BeTrue())
Expect(made.Applied.ChangeID).NotTo(BeEmpty())
Expect(made.Note).To(ContainSubstring("can undo it"))
// The data exists for its owner and nothing waits for a browser.
Expect(accessibleTo(h, "alice")).To(ContainElement(made.Applied.ResourceID))
Expect(h.pending("alice")).To(BeEmpty())
res := made.Applied.ResourceID
for _, step := range []struct{ tool, body string }{
{"tables-create", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res)},
{"columns-add", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","column":"Memo","type":"text"}`, res)},
{"rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{"Memo":"rent"}]}`, res)},
} {
out, e := chat.Call(ctx, "alice", "conv1", "addon__tables__"+step.tool, json.RawMessage(step.body))
Expect(e).NotTo(HaveOccurred(), step.tool)
Expect(out).To(ContainSubstring(`"kind":"applied"`), out)
}
Expect(h.pending("alice")).To(BeEmpty())
// The same tool through an API key is still a proposal that no tool can confirm.
kept, e := h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{"Memo":"food"}]}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(kept.Kind).To(Equal(tools.ResultProposal))
Expect(h.pending("alice")).To(HaveLen(1))
})
It("stores every change as a pending proposal and applies it only on the proposer's own session, exactly once", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
created, e := h.callTool("alice", "addon__tables__databases-create", `{"name":"Ledger"}`)
Expect(e).NotTo(HaveOccurred())
Expect(created.Kind).To(Equal(tools.ResultProposal))
ref := created.Proposal
Expect(ref.State).To(Equal("pending"))
Expect(ref.Count).To(Equal(uint64(1)))
Expect(ref.ResourceID).NotTo(BeEmpty())
// Nothing exists yet, for anyone, including a read by the same agent.
Expect(accessibleTo(h, "alice")).To(BeEmpty())
missing, e := h.callTool("alice", "addon__tables__databases-inspect", fmt.Sprintf(`{"resourceId":%q}`, ref.ResourceID))
Expect(e).NotTo(HaveOccurred())
Expect(missing.Kind).To(Equal(tools.ResultError))
Expect(missing.Code).To(Equal(tools.CodeResourceUnreachable))
// The proposal waits in the proposer's own list, not in anyone else's.
listed := h.pending("alice")
Expect(listed).To(HaveLen(1))
Expect(listed[0]["id"]).To(Equal(ref.ID))
Expect(listed[0]["digest"]).To(Equal(ref.Digest))
Expect(h.pending("bob")).To(BeEmpty())
// The card's authoritative record is the same one the tool referenced.
card := h.send("GET", "/api/addons/proposals/"+ref.ID, "", h.bearer("alice"))
Expect(card.code).To(Equal(200))
Expect(card.body["resourceId"]).To(Equal(ref.ResourceID))
Expect(card.body["effects"].(map[string]any)["count"]).To(BeEquivalentTo(1))
Expect(h.send("GET", "/api/addons/proposals/"+ref.ID, "", h.bearer("bob")).code).To(Equal(403))
// A tool call, a key and another user's session cannot approve.
Expect(h.send("POST", "/api/addons/proposals/"+ref.ID+"/confirm", fmt.Sprintf(`{"digest":%q}`, ref.Digest), h.bearer("alice")).code).To(Equal(403))
toolCall := func(r *http.Request) { h.humanTab("alice", ref.ID)(r); r.Header.Set("X-LocalAI-Tool-Call", "1") }
Expect(h.send("POST", "/api/addons/proposals/"+ref.ID+"/confirm", fmt.Sprintf(`{"digest":%q}`, ref.Digest), toolCall).code).To(Equal(403))
Expect(h.confirm("bob", ref.ID, ref.Digest).code).To(Equal(403))
Expect(accessibleTo(h, "alice")).To(BeEmpty())
done := h.confirm("alice", ref.ID, ref.Digest)
Expect(done.code).To(Equal(200), "%v", done.body)
Expect(done.body["state"]).To(Equal("applied"))
Expect(h.pending("alice")).To(BeEmpty())
res := ref.ResourceID
step := func(name, body string) *tools.ProposalRef {
GinkgoHelper()
r, e := h.callTool("alice", name, body)
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultProposal))
ok := h.confirm("alice", r.Proposal.ID, r.Proposal.Digest)
Expect(ok.code).To(Equal(200), "%v", ok.body)
Expect(ok.body["state"]).To(Equal("applied"), "%v", ok.body)
return r.Proposal
}
step("addon__tables__tables-create", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
step("addon__tables__columns-add", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","column":"Memo","type":"text"}`, res))
rows, e := h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{"Memo":"rent"},{"Memo":"food"}]}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(rows.Proposal.Count).To(Equal(uint64(2)))
count := func() int {
GinkgoHelper()
r, e := h.callTool("alice", "addon__tables__rows-query", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultData))
var p page
Expect(json.Unmarshal(r.Result, &p)).To(Succeed())
return len(p.Rows)
}
Expect(count()).To(Equal(0), "a proposal changes nothing")
Expect(h.confirm("alice", rows.Proposal.ID, rows.Proposal.Digest).code).To(Equal(200))
Expect(count()).To(Equal(2))
// A repeated confirmation returns the stored receipt and runs nothing.
again := h.confirm("alice", rows.Proposal.ID, rows.Proposal.Digest)
Expect(again.body["state"]).To(Equal("applied"))
Expect(count()).To(Equal(2))
})
It("keeps the roles of the host for tool calls and rejects forged fields before the owner sees them", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
ctx := context.Background()
created, e := h.callTool("alice", "addon__tables__databases-create", `{"name":"Team"}`)
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", created.Proposal.ID, created.Proposal.Digest).code).To(Equal(200))
res := created.Proposal.ResourceID
mk := func(table string) {
r, e := h.callTool("alice", "addon__tables__tables-create", fmt.Sprintf(`{"resourceId":%q,"table":%q}`, res, table))
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", r.Proposal.ID, r.Proposal.Digest).code).To(Equal(200))
}
mk("Entries")
// A stranger sees nothing: the same typed result for every tool, read
// or write. The model learns "no such resource or no access" and
// nothing that tells the two apart.
denied := func(r tools.ToolResult, e error) {
GinkgoHelper()
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultError))
Expect(r.Code).To(Equal(tools.CodeResourceUnreachable))
Expect(r.Note).To(Equal(tools.ResourceUnreachableNote))
Expect(r.Result).To(BeEmpty())
Expect(r.Proposal).To(BeNil())
}
for _, call := range []struct{ tool, body string }{
{"addon__tables__databases-inspect", fmt.Sprintf(`{"resourceId":%q}`, res)},
{"addon__tables__rows-query", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res)},
{"addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}]}`, res)},
{"addon__tables__tables-drop", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res)},
} {
denied(h.callTool("bob", call.tool, call.body))
}
key := policy.ResourceKey{AddonID: "tables", ResourceID: res}
Expect(h.app.AddonPolicy().SetGrant(ctx, h.actor("alice"), key, "bob", policy.RoleViewer)).To(Succeed())
_, e = h.callTool("bob", "addon__tables__rows-query", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
Expect(e).NotTo(HaveOccurred())
denied(h.callTool("bob", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}]}`, res)))
Expect(h.app.AddonPolicy().SetGrant(ctx, h.actor("alice"), key, "bob", policy.RoleEditor)).To(Succeed())
r, e := h.callTool("bob", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}]}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultProposal))
// An editor never changes the schema, by tool or by any other name.
denied(h.callTool("bob", "addon__tables__tables-drop", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res)))
before := len(h.pending("bob"))
// Forged identity, role and approval fields and approval aliases never
// create a proposal and never reach the addon.
for _, field := range []string{`"actor":"alice"`, `"userId":"alice"`, `"role":"owner"`, `"approved":true`, `"proposalId":"x"`, `"tool":"tables-drop"`} {
_, e := h.callTool("bob", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}],%s}`, res, field))
Expect(e).To(MatchError(tools.ErrForbiddenArgument), field)
}
for _, name := range []string{"confirm", "approve", "addon__tables__confirm", "addon__tables__share", "addon__tables__databases-delete", "addon__tables__databases-purge"} {
_, e := h.callTool("bob", name, fmt.Sprintf(`{"resourceId":%q}`, res))
Expect(e).To(MatchError(tools.ErrUnknownTool), name)
}
Expect(h.pending("bob")).To(HaveLen(before))
})
It("lists at most the bound of pending proposals and refuses more than the user's quota", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
created, e := h.callTool("alice", "addon__tables__databases-create", `{"name":"Many"}`)
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", created.Proposal.ID, created.Proposal.Digest).code).To(Equal(200))
res := created.Proposal.ResourceID
for _, step := range []string{
fmt.Sprintf(`{"resourceId":%q,"table":"T"}`, res),
} {
r, e := h.callTool("alice", "addon__tables__tables-create", step)
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", r.Proposal.ID, r.Proposal.Digest).code).To(Equal(200))
}
for i := 0; i < policy.MaxProposalsPerUser; i++ {
_, e := h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"T","rows":[{}]}`, res))
Expect(e).NotTo(HaveOccurred(), "proposal %d", i)
}
Expect(len(h.pending("alice"))).To(Equal(gateway.MaxPendingListed))
_, e = h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"T","rows":[{}]}`, res))
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
})
})