From 9f4b3f5c40b55bf1f5d4e69d07d04f990ed86cb5 Mon Sep 17 00:00:00 2001
From: david <58231312+davseby@users.noreply.github.com>
Date: Tue, 6 Oct 2026 16:29:48 +0300
Subject: [PATCH] replace markdown assistant with xml
---
e2e/tests/search.test.ts | 7 +-
server/auth-realtime/AGENTS.md | 3 +-
server/auth-realtime/src/operations.test.ts | 1268 +++++----------
server/auth-realtime/src/operations.ts | 599 ++------
server/auth-realtime/src/routes.test.ts | 7 +-
server/auth-realtime/src/routes.ts | 17 +-
server/auth-realtime/src/schema/code-block.ts | 4 +-
server/auth-realtime/src/schema/index.test.ts | 109 +-
server/auth-realtime/src/schema/index.ts | 4 +
server/auth-realtime/src/schema/metric.ts | 2 +-
.../auth-realtime/src/schema/node-comment.ts | 20 +
.../src/schema/parameter-list.ts | 12 +-
.../src/schema/split-documentation.ts | 4 +-
server/auth-realtime/src/validate.test.ts | 217 +++
server/auth-realtime/src/validate.ts | 113 ++
server/auth-realtime/src/ydocument.ts | 39 +-
server/core/internal/apps/registry/client.go | 63 +-
.../internal/apps/registry/client_test.go | 21 +-
server/core/internal/assistant/AGENTS.md | 65 +-
server/core/internal/assistant/block/block.go | 303 ----
.../internal/assistant/block/block_test.go | 130 --
.../core/internal/assistant/block/compact.go | 533 -------
.../internal/assistant/block/compact_test.go | 585 -------
.../core/internal/assistant/block/expand.go | 520 -------
.../internal/assistant/block/expand_test.go | 784 ----------
.../core/internal/assistant/block/markdown.go | 738 ---------
.../internal/assistant/block/markdown_test.go | 574 -------
.../core/internal/assistant/block/metric.go | 356 -----
.../internal/assistant/block/metric_test.go | 465 ------
server/core/internal/assistant/block/types.go | 120 --
.../internal/assistant/block/types_test.go | 111 --
.../core/internal/assistant/block/validate.go | 851 -----------
.../internal/assistant/block/validate_test.go | 1003 ------------
server/core/internal/assistant/edit/client.go | 45 +-
.../internal/assistant/edit/client_test.go | 76 +-
server/core/internal/assistant/edit/error.go | 42 +-
.../internal/assistant/edit/error_test.go | 54 +-
.../core/internal/assistant/edit/operation.go | 290 ++--
.../internal/assistant/edit/operation_test.go | 125 +-
.../core/internal/assistant/manager_test.go | 6 +-
.../core/internal/assistant/markup/build.go | 540 +++++++
.../internal/assistant/markup/build_test.go | 398 +++++
.../internal/assistant/markup/describe.go | 100 ++
.../assistant/markup/describe_test.go | 53 +
.../core/internal/assistant/markup/inline.go | 82 +
.../internal/assistant/markup/inline_test.go | 77 +
.../core/internal/assistant/markup/markup.go | 113 ++
.../core/internal/assistant/markup/metric.go | 215 +++
.../internal/assistant/markup/metric_test.go | 223 +++
.../core/internal/assistant/markup/parse.go | 366 +++++
.../internal/assistant/markup/parse_test.go | 174 +++
.../core/internal/assistant/markup/render.go | 297 ++++
.../internal/assistant/markup/render_test.go | 135 ++
.../assistant/markup/testdata/all_blocks.json | 632 ++++++++
.../assistant/markup/testdata/all_blocks.xml | 50 +
.../markup/testdata/empty_parts.json | 131 ++
.../assistant/markup/testdata/empty_parts.xml | 14 +
.../assistant/markup/testdata/scratch.json | 480 ++++++
.../assistant/markup/testdata/scratch.xml | 34 +
.../assistant/markup/testdata/welcome.json | 682 +++++++++
.../assistant/markup/testdata/welcome.xml | 40 +
server/core/internal/assistant/markup/text.go | 217 +++
.../internal/assistant/markup/text_test.go | 138 ++
.../core/internal/assistant/metrics_test.go | 22 +-
.../assistant/middleware/compact_test.go | 8 +-
.../assistant/middleware/observer_test.go | 12 +-
server/core/internal/assistant/prompt.go | 131 +-
server/core/internal/assistant/prompt_test.go | 57 +-
.../internal/assistant/protocol/protocol.go | 4 +-
.../core/internal/assistant/session_test.go | 12 +-
server/core/internal/assistant/tools/block.go | 906 ++---------
.../internal/assistant/tools/block_test.go | 1358 ++++-------------
.../core/internal/assistant/tools/confirm.go | 23 +-
.../internal/assistant/tools/confirm_test.go | 55 +-
.../internal/assistant/tools/datasource.go | 946 ++++--------
.../assistant/tools/datasource_test.go | 1000 ++++--------
.../core/internal/assistant/tools/document.go | 260 +---
.../internal/assistant/tools/document_test.go | 166 +-
.../core/internal/assistant/tools/docwalk.go | 271 ----
.../internal/assistant/tools/docwalk_test.go | 356 -----
server/core/internal/assistant/tools/eino.go | 86 +-
.../internal/assistant/tools/eino_test.go | 86 +-
server/core/internal/assistant/tools/hook.go | 411 ++---
.../internal/assistant/tools/hook_test.go | 500 +++---
server/core/internal/assistant/tools/input.go | 553 +++----
.../internal/assistant/tools/input_test.go | 901 ++++-------
.../core/internal/assistant/tools/schema.go | 315 ----
.../internal/assistant/tools/schema_test.go | 122 --
.../core/internal/assistant/tools/search.go | 80 +-
.../internal/assistant/tools/search_test.go | 56 +-
server/core/internal/assistant/tools/tag.go | 522 ++-----
.../core/internal/assistant/tools/tag_test.go | 839 ++++------
.../tools/testdata/tool_schemas.golden | 2 +-
server/core/internal/assistant/tools/tool.go | 352 +----
.../internal/assistant/tools/tool_test.go | 85 --
server/core/internal/assistant/tools/tools.go | 117 +-
.../internal/assistant/tools/tools_test.go | 93 +-
server/core/internal/assistant/tools/util.go | 65 +-
.../internal/assistant/tools/util_test.go | 37 +
server/core/internal/assistant/turn_test.go | 8 +-
server/core/internal/buildinfo/buildinfo.go | 11 -
.../core/internal/buildinfo/buildinfo_test.go | 17 -
.../datasource/simulation/simulation.go | 7 +-
.../datasource/simulation/simulation_test.go | 21 +-
server/core/internal/document/attribute.go | 34 +-
.../core/internal/document/attribute_keys.go | 21 +-
.../core/internal/document/attribute_test.go | 44 +-
server/core/internal/document/document.go | 20 -
.../core/internal/document/history/history.go | 2 +-
server/core/internal/document/hook/hook.go | 3 +-
.../internal/document/hook/manager/write.go | 5 +
.../document/hook/manager/write_test.go | 1 +
server/core/internal/document/node.go | 116 +-
server/core/internal/document/node_test.go | 176 +--
.../internal/server/internal/mcp/bridge.go | 27 +-
.../server/internal/mcp/bridge_test.go | 57 +-
.../internal/server/internal/mcp/http_test.go | 2 +-
.../internal/server/internal/mcp/resources.go | 6 +-
.../server/internal/mcp/resources_test.go | 2 +-
.../internal/server/internal/mcp/server.go | 28 +-
.../server/internal/mcp/server_test.go | 52 +-
server/core/pkg/strutil/nanoid.go | 2 +
server/core/pkg/strutil/nanoid_test.go | 5 +
server/core/pkg/strutil/strutil.go | 30 -
server/core/pkg/strutil/strutil_test.go | 57 -
web/app/composables/useAIChat.nuxt.test.ts | 4 +-
126 files changed, 9528 insertions(+), 17515 deletions(-)
create mode 100644 server/auth-realtime/src/schema/node-comment.ts
create mode 100644 server/auth-realtime/src/validate.test.ts
create mode 100644 server/auth-realtime/src/validate.ts
delete mode 100644 server/core/internal/assistant/block/block.go
delete mode 100644 server/core/internal/assistant/block/block_test.go
delete mode 100644 server/core/internal/assistant/block/compact.go
delete mode 100644 server/core/internal/assistant/block/compact_test.go
delete mode 100644 server/core/internal/assistant/block/expand.go
delete mode 100644 server/core/internal/assistant/block/expand_test.go
delete mode 100644 server/core/internal/assistant/block/markdown.go
delete mode 100644 server/core/internal/assistant/block/markdown_test.go
delete mode 100644 server/core/internal/assistant/block/metric.go
delete mode 100644 server/core/internal/assistant/block/metric_test.go
delete mode 100644 server/core/internal/assistant/block/types.go
delete mode 100644 server/core/internal/assistant/block/types_test.go
delete mode 100644 server/core/internal/assistant/block/validate.go
delete mode 100644 server/core/internal/assistant/block/validate_test.go
create mode 100644 server/core/internal/assistant/markup/build.go
create mode 100644 server/core/internal/assistant/markup/build_test.go
create mode 100644 server/core/internal/assistant/markup/describe.go
create mode 100644 server/core/internal/assistant/markup/describe_test.go
create mode 100644 server/core/internal/assistant/markup/inline.go
create mode 100644 server/core/internal/assistant/markup/inline_test.go
create mode 100644 server/core/internal/assistant/markup/markup.go
create mode 100644 server/core/internal/assistant/markup/metric.go
create mode 100644 server/core/internal/assistant/markup/metric_test.go
create mode 100644 server/core/internal/assistant/markup/parse.go
create mode 100644 server/core/internal/assistant/markup/parse_test.go
create mode 100644 server/core/internal/assistant/markup/render.go
create mode 100644 server/core/internal/assistant/markup/render_test.go
create mode 100644 server/core/internal/assistant/markup/testdata/all_blocks.json
create mode 100644 server/core/internal/assistant/markup/testdata/all_blocks.xml
create mode 100644 server/core/internal/assistant/markup/testdata/empty_parts.json
create mode 100644 server/core/internal/assistant/markup/testdata/empty_parts.xml
create mode 100644 server/core/internal/assistant/markup/testdata/scratch.json
create mode 100644 server/core/internal/assistant/markup/testdata/scratch.xml
create mode 100644 server/core/internal/assistant/markup/testdata/welcome.json
create mode 100644 server/core/internal/assistant/markup/testdata/welcome.xml
create mode 100644 server/core/internal/assistant/markup/text.go
create mode 100644 server/core/internal/assistant/markup/text_test.go
delete mode 100644 server/core/internal/assistant/tools/docwalk.go
delete mode 100644 server/core/internal/assistant/tools/docwalk_test.go
delete mode 100644 server/core/internal/assistant/tools/schema.go
delete mode 100644 server/core/internal/assistant/tools/schema_test.go
delete mode 100644 server/core/internal/assistant/tools/tool_test.go
delete mode 100644 server/core/pkg/strutil/strutil.go
delete mode 100644 server/core/pkg/strutil/strutil_test.go
diff --git a/e2e/tests/search.test.ts b/e2e/tests/search.test.ts
index 0c980261..447cae03 100644
--- a/e2e/tests/search.test.ts
+++ b/e2e/tests/search.test.ts
@@ -126,16 +126,13 @@ test.describe("search", () => {
expect(branchId).toBeDefined()
const inserted = await client.callTool({
- name: "insert_block",
+ name: "insert_blocks",
arguments: {
document_id: id,
branch_id: branchId,
position: "end",
// a metric lives in a grid; the root refuses a bare one
- block: {
- type: "metric_grid",
- items: [{ type: "metric", attrs: { title: title } }],
- },
+ content: ` is not allowed there in ")
+ if err != nil {
+ return nil, err
+ }
+
+ var out []document.Block
+
+ for _, n := range elems {
+ stored, err := b.replaced(n)
+ if err != nil {
+ return nil, err
+ }
+
+ blk, err := b.build(n, stored)
+ if err != nil {
+ return nil, err
+ }
+
+ out = append(out, keepIfUnchanged(stored, blk))
+ }
+
+ return out, nil
+}
+
+// replaced returns the stored block n's id names, or the zero block for
+// an element without an id.
+func (b *builder) replaced(n node) (document.Block, error) {
+ id, ok := n.attrs[_attrID]
+ if !ok {
+ return document.Block{}, nil
+ }
+
+ if b.used[id] {
+ return document.Block{}, fmt.Errorf("line %d: id %q is used twice; leave it out of the copy", n.line, id)
+ }
+
+ stored, found := b.stored.FindByUID(id)
+ if !found {
+ return document.Block{}, fmt.Errorf("line %d: id %q is not a block this write can keep; leave id out for a new block", n.line, id)
+ }
+
+ b.used[id] = true
+
+ return stored, nil
+}
+
+// build turns one element into its block, nested ones included. stored
+// is the block it replaces, or the zero block.
+func (b *builder) build(n node, stored document.Block) (document.Block, error) {
+ if c, ok := _containers[n.name]; ok {
+ return b.buildContainer(n, c.Type, c.Child, stored)
+ }
+
+ if a, ok := _atoms[n.name]; ok {
+ return buildAtom(n, a.Type, a.Strings, a.Ints, stored)
+ }
+
+ switch n.name {
+ case _elemParagraph, "h1", "h2", "h3":
+ return buildText(n, stored)
+ case _elemEntry, _elemTask:
+ return b.buildEntry(n, stored)
+ case _elemCode:
+ return buildCode(n, stored), nil
+ case _elemMermaid:
+ return newBlock(document.BlockNodeMermaidBlock, stored, textNodes([]run{{text: n.text}}, stored.Content)), nil
+ case _elemRule:
+ return newBlock(document.BlockNodeHorizontalRule, stored, nil), nil
+ case _elemMetric:
+ return buildMetric(n, stored)
+ case _elemSplitDoc:
+ return b.buildSplitDoc(n, stored)
+ case _elemParams:
+ return b.buildParams(n, stored)
+ case _elemFile, _elemUnsupported:
+ // neither can be written: an uploaded file or a node this model
+ // does not know is only ever kept as it is, by its own element.
+ _, known := _nodeNames[stored.Type]
+ if stored.Type != document.BlockNodeFileBlock && (known || stored.Type == "") {
+ return document.Block{}, fmt.Errorf("line %d: <%s> can only keep a block get_document showed as <%s>, by its id", n.line, n.name, n.name)
+ }
+
+ return stored, nil
+ case _elemLeft, _elemRight:
+ return document.Block{}, fmt.Errorf("line %d: <%s> belongs inside <%s>", n.line, n.name, _elemSplitDoc)
+ case _elemParam:
+ return document.Block{}, fmt.Errorf("line %d: <%s> belongs inside <%s>", n.line, n.name, _elemParams)
+ default:
+ return document.Block{}, fmt.Errorf("line %d: <%s> is inline; put it inside a ", n.line, n.name)
+ }
+}
+
+// buildText builds a paragraph or a heading. A heading takes plain text,
+// its level from the element name.
+func buildText(n node, stored document.Block) (document.Block, error) {
+ paragraph := n.name == _elemParagraph
+
+ for _, c := range n.children {
+ if _, isTag := _tagMarks[c.name]; isTag && !paragraph {
+ return document.Block{}, fmt.Errorf("line %d: a heading takes plain text, so <%s> is not allowed in it", c.line, c.name)
+ }
+ }
+
+ content, err := inline(n.children, stored.Content)
+ if err != nil {
+ return document.Block{}, err
+ }
+
+ if paragraph {
+ return newBlock(document.BlockNodeParagraph, stored, content), nil
+ }
+
+ blk := newBlock(document.BlockNodeHeading, stored, content)
+ blk.Attrs[document.AttrLevel] = int(n.name[1] - '0')
+
+ return blk, nil
+}
+
+// buildContainer builds an element holding blocks. child, when set, is
+// the one element name the container holds.
+func (b *builder) buildContainer(n node, typ document.BlockNodeType, child string, stored document.Block) (document.Block, error) {
+ if child != "" {
+ for _, c := range n.children {
+ if c.name != "" && c.name != child {
+ return document.Block{}, fmt.Errorf("line %d: <%s> holds only <%s>, not <%s>", c.line, n.name, child, c.name)
+ }
+ }
+ }
+
+ content, err := b.blocks(n.children, "<"+n.name+">")
+ if err != nil {
+ return document.Block{}, err
+ }
+
+ blk := newBlock(typ, stored, content)
+
+ switch typ {
+ case document.BlockNodeCalloutBlock:
+ blk.Attrs[document.AttrIcon] = cmp.Or(n.attrs[document.AttrIcon], _defaultCalloutIcon)
+ case document.BlockNodeOrderedList:
+ if start, err := strconv.Atoi(n.attrs[document.AttrStart]); err == nil && start > 1 {
+ blk.Attrs[document.AttrStart] = start
+ }
+ default:
+ }
+
+ return blk, nil
+}
+
+// buildEntry builds a list or task entry: the text before its first
+// element is the entry's paragraph, and the elements after it are nested
+// under it.
+func (b *builder) buildEntry(n node, stored document.Block) (document.Block, error) {
+ split := len(n.children)
+ blank := true
+
+ for i, c := range n.children {
+ if _, inlineTag := _tagMarks[c.name]; c.name != "" && !inlineTag {
+ split = i
+
+ break
+ }
+
+ blank = blank && c.name == "" && strings.TrimSpace(c.text) == ""
+ }
+
+ textNodes, nestedNodes := n.children[:split], n.children[split:]
+
+ // models used to HTML often wrap an entry's text in a . A
+ // with an id is a block the markup keeps, so it stays nested.
+ if blank && split < len(n.children) {
+ first := n.children[split]
+
+ if _, hasID := first.attrs[_attrID]; first.name == _elemParagraph && !hasID {
+ textNodes, nestedNodes = first.children, n.children[split+1:]
+ }
+ }
+
+ typ := document.BlockNodeListItem
+ if n.name == _elemTask {
+ typ = document.BlockNodeTaskItem
+ }
+
+ storedParagraph := stored.FirstChild(document.BlockNodeParagraph)
+
+ text, err := inline(textNodes, storedParagraph.Content)
+ if err != nil {
+ return document.Block{}, err
+ }
+
+ nested, err := b.blocks(nestedNodes, "<"+n.name+"> after its text")
+ if err != nil {
+ return document.Block{}, err
+ }
+
+ blk := newBlock(typ, stored, append([]document.Block{newBlock(document.BlockNodeParagraph, storedParagraph, text)}, nested...))
+
+ if typ == document.BlockNodeTaskItem {
+ blk.Attrs[document.AttrChecked] = n.attrs[document.AttrChecked] == "true"
+ }
+
+ return blk, nil
+}
+
+// buildCode builds a pre: a titled code block when it carries a title,
+// a code block otherwise.
+func buildCode(n node, stored document.Block) document.Block {
+ title, titled := n.attrs[document.AttrTitle]
+
+ storedCode := stored
+ if titled {
+ storedCode = stored.FirstChild(document.BlockNodeCodeBlock)
+ }
+
+ codeBlock := newBlock(document.BlockNodeCodeBlock, storedCode, textNodes([]run{{text: n.text}}, storedCode.Content))
+ if lang := n.attrs[document.AttrLanguage]; lang != "" {
+ codeBlock.Attrs[document.AttrLanguage] = lang
+ }
+
+ if !titled {
+ return codeBlock
+ }
+
+ return newBlock(document.BlockNodeTitledCodeBlock, stored, []document.Block{
+ titleBlock(document.BlockNodeCodeBlockTitle, title, stored),
+ codeBlock,
+ })
+}
+
+// buildSplitDoc builds a split documentation block from its left and
+// right sides.
+func (b *builder) buildSplitDoc(n node, stored document.Block) (document.Block, error) {
+ sides, err := elements(n.children, " Plain \n Wrapped\n text with strong\n words.\n Wrapped text with strong words. a b a b ab ab x x", Err: errors.New("line 1: is never closed")},
+ "Unknown id": {Markup: ` x x y ")},
+ "Text in a container": {Markup: " ")},
+ "Tag in a heading": {Markup: " a x ")},
+ "Text in params": {Markup: ` x ")},
+ "Entry with text after a list": {
+ Markup: " "),
+ },
+ }
+
+ // markup read and written back unchanged is the stored document
+ // itself, hidden marks and parts included. Without ids it builds
+ // new blocks that read back the same.
+ for _, name := range _fixtures {
+ fixture := loadFixture(t, name)
+ rendered := Render(fixture.Content)
+ withoutIDs := _keptOnly.ReplaceAllString(_idAttr.ReplaceAllString(rendered, ""), "")
+
+ maps.Copy(cc, map[string]tcase{
+ name + " kept as it is": {
+ Markup: rendered,
+ Stored: fixture,
+ Check: func(t *testing.T, got []document.Block) {
+ t.Helper()
+ assert.Equal(t, fixture.Content, got)
+ },
+ },
+ name + " written as new": {
+ Markup: withoutIDs,
+ Result: withoutIDs,
+ },
+ })
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := Build(c.Markup, c.Stored)
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ if c.Result != "" {
+ assert.Equal(t, c.Result, _idAttr.ReplaceAllString(Render(got), ""))
+ }
+
+ if c.Check != nil {
+ c.Check(t, got)
+ }
+ })
+ }
+}
+
+func Test_elements(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Markup string
+ Result string
+ Err error
+ }{
+ "Elements in order, blank text dropped": {
+ Markup: " a a ",
+ document.BlockNodeHeading: "a heading",
+ document.BlockNodeBlockquote: " ", DescribeNode(document.BlockNodeParagraph))
+ assert.Equal(t, " Hi bold both and link 1 < 2 and a b & c < d x y see this a &ersand; b a b c < d a b b or
at that point.",
},
- "Node type without a canonical type named by its block": {
- Msg: "update_attrs does not apply to codeBlockTitle in nrk_7bSz-mJp",
- Result: "update_attrs does not apply to a titled_code's title in nrk_7bSz-mJp",
+ "Node type without an element named by the element holding it": {
+ Msg: "codeBlockTitle would end without text in nrk_7bSz-mJp",
+ Result: "a titled
's title would end without text in nrk_7bSz-mJp",
},
"Unknown message passes through": {
Msg: "cannot move a block relative to itself: a",
@@ -48,15 +50,39 @@ func Test_OpError_describe(t *testing.T) {
}
}
-func Test_JoinOpErrors(t *testing.T) {
+func Test_Result_Err(t *testing.T) {
t.Parallel()
- got := JoinOpErrors([]OpError{
- {Index: 0, Message: "block_uid not found: a"},
- {Index: 1, Message: "something else"},
- })
+ errs := []OpError{{Index: 2, Message: "block_uid not found: a"}}
- // each message is rewritten for the model and the index is left out,
- // since a tool ships one operation.
- assert.Equal(t, "no block with uid a in this document; call get_document for the current uids; something else", got)
+ // the message is rewritten for the model. The index and the prefix
+ // only say something when the batch held more than one operation.
+ cc := map[string]struct {
+ Result Result
+ Ops int
+ Err error
+ }{
+ "No errors": {
+ Result: Result{},
+ Ops: 3,
+ },
+ "Single operation": {
+ Result: Result{Errors: errs},
+ Ops: 1,
+ Err: errors.New("no block with uid a in this document; call get_document for the current uids"),
+ },
+ "Several operations": {
+ Result: Result{Errors: errs},
+ Ops: 3,
+ Err: errors.New("nothing was applied; operation 3: no block with uid a in this document; call get_document for the current uids"),
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ testutil.AssertEqualError(t, c.Err, c.Result.Err(c.Ops))
+ })
+ }
}
diff --git a/server/core/internal/assistant/edit/operation.go b/server/core/internal/assistant/edit/operation.go
index c7fc1318..e80a963a 100644
--- a/server/core/internal/assistant/edit/operation.go
+++ b/server/core/internal/assistant/edit/operation.go
@@ -2,7 +2,7 @@
// operations endpoint that mutates live Y.Docs. AI-generated edits
// flow:
//
-// block.Block --block.Expand--> document.Block (PM JSON)
+// markup --markup.Build--> document.Block (PM JSON)
// |
// v
// edit.Operation --Apply--> POST /api/internal/.../operations
@@ -12,169 +12,22 @@
// one transaction, broadcasts to subscribers, and disconnects.
//
// Use the package-level constructors (InsertAfter, Append,
-// UpdateText, …) to build operations; Client.Apply ships them to
-// Node and returns a per-op outcome.
+// Replace, …) to build operations; Client.Apply ships them to
+// Node, which applies the whole batch or none of it.
package edit
import (
"github.com/oxynote/oxynote/server/core/internal/document"
)
-// Operation is one edit to apply to a live document. An Operation
-// returns the JSON shape expected by the Node endpoint; a failure to
-// produce one is reported as an error so Apply can fail fast before
-// the round-trip. Operations are constructed via the package-level
-// helpers (InsertAfter, InsertBefore, Append, Prepend, Replace,
-// UpdateText, UpdateAttrs, Delete, SetName, SetIcon) and applied as a
-// batch via Client.Apply. A block-carrying operation takes the
-// expanded editor tree, uids already resolved, so the caller can
-// report the uids the write lands with.
-type Operation func() (wireOp, error)
-
-// InsertAfter builds an operation that inserts b immediately after
-// the block identified by referenceUID. b is the expanded editor
-// tree, uids resolved, so the caller knows the uids the write lands
-// with.
-func InsertAfter(referenceUID string, b document.Block) Operation {
- return insert("after", referenceUID, b)
-}
-
-// InsertBefore builds an operation that inserts b immediately
-// before the block identified by referenceUID.
-func InsertBefore(referenceUID string, b document.Block) Operation {
- return insert("before", referenceUID, b)
-}
-
-// insert builds the shared wire closure behind InsertAfter and
-// InsertBefore.
-func insert(position, referenceUID string, b document.Block) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "insert",
- Position: position,
- ReferenceUID: referenceUID,
- Block: &b,
- }, nil
- }
-}
-
-// Append builds an operation that adds b at the end of the
-// document's top-level content.
-func Append(b document.Block) Operation {
- return func() (wireOp, error) {
- return wireOp{Kind: "append", Block: &b}, nil
- }
-}
-
-// Prepend builds an operation that adds b at the start of the
-// document's top-level content.
-func Prepend(b document.Block) Operation {
- return func() (wireOp, error) {
- return wireOp{Kind: "prepend", Block: &b}, nil
- }
-}
-
-// Replace builds an operation that replaces the block identified by
-// blockUID with b. The replacement preserves the original position
-// in its parent and lands with the uids b carries.
-func Replace(blockUID string, b document.Block) Operation {
- return func() (wireOp, error) {
- return wireOp{Kind: "replace", BlockUID: blockUID, Block: &b}, nil
- }
-}
-
-// UpdateText builds an operation that replaces the inline text of a
-// text-bearing block, leaving its type and attrs unchanged. content
-// is the inline content as block.TextContent parses it for the block.
-func UpdateText(blockUID string, content []document.Block) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "update_text",
- BlockUID: blockUID,
- Content: content,
- }, nil
- }
-}
-
-// UpdateAttrs builds an operation that sets the named attributes on
-// an existing block. Unmentioned attrs are preserved; the uid attr
-// cannot be changed and is silently ignored on the Node side.
-func UpdateAttrs(blockUID string, attrs map[string]any) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "update_attrs",
- BlockUID: blockUID,
- Attrs: attrs,
- }, nil
- }
-}
-
-// Delete builds an operation that removes the block identified by
-// blockUID from the document.
-func Delete(blockUID string) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "delete",
- BlockUID: blockUID,
- }, nil
- }
-}
-
-// MoveAfter builds an operation that repositions the block identified
-// by blockUID to immediately after the block identified by
-// referenceUID, keeping the block's uid, attrs, and nested content.
-func MoveAfter(blockUID, referenceUID string) Operation {
- return move("after", blockUID, referenceUID)
-}
-
-// MoveBefore builds an operation that repositions the block
-// identified by blockUID to immediately before the block identified
-// by referenceUID.
-func MoveBefore(blockUID, referenceUID string) Operation {
- return move("before", blockUID, referenceUID)
-}
-
-// move builds the shared wire closure behind MoveAfter and
-// MoveBefore.
-func move(position, blockUID, referenceUID string) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "move",
- Position: position,
- BlockUID: blockUID,
- ReferenceUID: referenceUID,
- }, nil
- }
-}
-
-// SetName builds an operation that updates the document's display
-// name. The op is sent even when name is empty: omitempty drops the
-// name field from the wire form, and Node treats the missing name as
-// falsy and clears the title.
-func SetName(name string) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "set_name",
- Name: name,
- }, nil
- }
-}
-
-// SetIcon builds an operation that updates the document's icon
-// identifier (e.g. "lucide:rocket").
-func SetIcon(icon string) Operation {
- return func() (wireOp, error) {
- return wireOp{
- Kind: "set_icon",
- Icon: icon,
- }, nil
- }
-}
-
-// wireOp is the JSON shape the Node endpoint expects for one
-// operation. Only the fields meaningful to a given kind are set;
-// omitempty keeps the wire payload tight.
-type wireOp struct {
+// Operation is one edit to apply to a live document, in the JSON shape
+// the Node endpoint expects. Only the fields meaningful to its kind are
+// set; omitempty keeps the wire payload tight. Operations are built with
+// the package-level constructors and applied as a batch via
+// Client.Apply. A block-carrying operation takes the expanded editor
+// tree, uids already resolved, so the caller can report the uids the
+// write lands with.
+type Operation struct {
// Kind names the operation ("insert", "append", …); set on
// every op.
Kind string `json:"kind"`
@@ -188,17 +41,13 @@ type wireOp struct {
ReferenceUID string `json:"reference_uid,omitempty"`
// BlockUID identifies the block the operation targets; replace,
- // update_text, update_attrs, delete, and move.
+ // update_attrs, delete, and move.
BlockUID string `json:"block_uid,omitempty"`
// Block is the expanded ProseMirror payload; insert, append,
// prepend, and replace.
Block *document.Block `json:"block,omitempty"`
- // Content is the parsed inline replacement text; update_text
- // only.
- Content []document.Block `json:"content,omitempty"`
-
// Attrs holds the attributes to set; update_attrs only.
Attrs map[string]any `json:"attrs,omitempty"`
@@ -208,3 +57,118 @@ type wireOp struct {
// Icon is the new document icon identifier; set_icon only.
Icon string `json:"icon,omitempty"`
}
+
+// InsertAfter builds an operation that inserts b immediately after
+// the block identified by referenceUID.
+func InsertAfter(referenceUID string, b document.Block) Operation {
+ return Operation{
+ Kind: "insert",
+ Position: "after",
+ ReferenceUID: referenceUID,
+ Block: &b,
+ }
+}
+
+// InsertBefore builds an operation that inserts b immediately
+// before the block identified by referenceUID.
+func InsertBefore(referenceUID string, b document.Block) Operation {
+ return Operation{
+ Kind: "insert",
+ Position: "before",
+ ReferenceUID: referenceUID,
+ Block: &b,
+ }
+}
+
+// Append builds an operation that adds b at the end of the
+// document's top-level content.
+func Append(b document.Block) Operation {
+ return Operation{
+ Kind: "append",
+ Block: &b,
+ }
+}
+
+// Prepend builds an operation that adds b at the start of the
+// document's top-level content.
+func Prepend(b document.Block) Operation {
+ return Operation{
+ Kind: "prepend",
+ Block: &b,
+ }
+}
+
+// Replace builds an operation that replaces the block identified by
+// blockUID with b. The replacement preserves the original position
+// in its parent and lands with the uids b carries.
+func Replace(blockUID string, b document.Block) Operation {
+ return Operation{
+ Kind: "replace",
+ BlockUID: blockUID,
+ Block: &b,
+ }
+}
+
+// UpdateAttrs builds an operation that sets the named attributes on
+// an existing block. Unmentioned attrs are preserved; the uid attr
+// cannot be changed and is silently ignored on the Node side.
+func UpdateAttrs(blockUID string, attrs map[string]any) Operation {
+ return Operation{
+ Kind: "update_attrs",
+ BlockUID: blockUID,
+ Attrs: attrs,
+ }
+}
+
+// Delete builds an operation that removes the block identified by
+// blockUID from the document.
+func Delete(blockUID string) Operation {
+ return Operation{
+ Kind: "delete",
+ BlockUID: blockUID,
+ }
+}
+
+// MoveAfter builds an operation that repositions the block identified
+// by blockUID to immediately after the block identified by
+// referenceUID, keeping the block's uid, attrs, and nested content.
+func MoveAfter(blockUID, referenceUID string) Operation {
+ return Operation{
+ Kind: "move",
+ Position: "after",
+ BlockUID: blockUID,
+ ReferenceUID: referenceUID,
+ }
+}
+
+// MoveBefore builds an operation that repositions the block
+// identified by blockUID to immediately before the block identified
+// by referenceUID.
+func MoveBefore(blockUID, referenceUID string) Operation {
+ return Operation{
+ Kind: "move",
+ Position: "before",
+ BlockUID: blockUID,
+ ReferenceUID: referenceUID,
+ }
+}
+
+// SetName builds an operation that updates the document's display
+// name. The op is sent even when name is empty: omitempty drops the
+// name field from the wire form, and Node treats the missing name as
+// falsy and clears the title.
+func SetName(name string) Operation {
+ return Operation{
+ Kind: "set_name",
+ Name: name,
+ }
+}
+
+// SetIcon builds an operation that updates the document's icon
+// identifier (e.g. "lucide:rocket").
+func SetIcon(icon string) Operation {
+ return Operation{
+ Kind: "set_icon",
+ Icon: icon,
+ }
+}
diff --git a/server/core/internal/assistant/edit/operation_test.go b/server/core/internal/assistant/edit/operation_test.go
index 0d0a07bc..08bd0d7b 100644
--- a/server/core/internal/assistant/edit/operation_test.go
+++ b/server/core/internal/assistant/edit/operation_test.go
@@ -5,7 +5,6 @@ import (
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
// paragraph is the block every operation that carries one is built with,
@@ -18,168 +17,80 @@ func paragraph() document.Block {
}
}
-// wire runs the operation and returns the wire form it produced.
-func wire(t *testing.T, op Operation) wireOp {
- t.Helper()
-
- w, err := op()
- require.NoError(t, err)
-
- return w
-}
-
func Test_InsertAfter(t *testing.T) {
t.Parallel()
- w := wire(t, InsertAfter("ref-uid", paragraph()))
+ b := paragraph()
- assert.Equal(t, "insert", w.Kind)
- assert.Equal(t, "after", w.Position)
- assert.Equal(t, "ref-uid", w.ReferenceUID)
- require.NotNil(t, w.Block)
- assert.Equal(t, document.BlockNodeParagraph, w.Block.Type)
+ // the block ships as handed over, uids included, so what the caller
+ // reported is what lands.
+ assert.Equal(t, Operation{Kind: "insert", Position: "after", ReferenceUID: "ref", Block: &b}, InsertAfter("ref", paragraph()))
}
func Test_InsertBefore(t *testing.T) {
t.Parallel()
- w := wire(t, InsertBefore("ref", paragraph()))
+ b := paragraph()
- assert.Equal(t, "insert", w.Kind)
- assert.Equal(t, "before", w.Position)
- assert.Equal(t, "ref", w.ReferenceUID)
-}
-
-func Test_insert(t *testing.T) {
- t.Parallel()
-
- w := wire(t, insert("before", "ref-uid", paragraph()))
-
- assert.Equal(t, "insert", w.Kind)
- assert.Equal(t, "before", w.Position)
- assert.Equal(t, "ref-uid", w.ReferenceUID)
- require.NotNil(t, w.Block)
- assert.Equal(t, document.BlockNodeParagraph, w.Block.Type)
-
- // the block ships as handed over, uids included, so what the caller
- // reported is what lands.
- uid, ok := w.Block.UID()
- require.True(t, ok)
- assert.Equal(t, "p", uid)
+ assert.Equal(t, Operation{Kind: "insert", Position: "before", ReferenceUID: "ref", Block: &b}, InsertBefore("ref", paragraph()))
}
func Test_Append(t *testing.T) {
t.Parallel()
- w := wire(t, Append(paragraph()))
+ b := paragraph()
- assert.Equal(t, "append", w.Kind)
- assert.Empty(t, w.Position)
- assert.Empty(t, w.ReferenceUID)
- require.NotNil(t, w.Block)
+ assert.Equal(t, Operation{Kind: "append", Block: &b}, Append(paragraph()))
}
func Test_Prepend(t *testing.T) {
t.Parallel()
- w := wire(t, Prepend(paragraph()))
+ b := paragraph()
- assert.Equal(t, "prepend", w.Kind)
- require.NotNil(t, w.Block)
+ assert.Equal(t, Operation{Kind: "prepend", Block: &b}, Prepend(paragraph()))
}
func Test_Replace(t *testing.T) {
t.Parallel()
- w := wire(t, Replace("target", paragraph()))
+ b := paragraph()
- assert.Equal(t, "replace", w.Kind)
- assert.Equal(t, "target", w.BlockUID)
- require.NotNil(t, w.Block)
-}
-
-func Test_UpdateText(t *testing.T) {
- t.Parallel()
-
- content := []document.Block{{Type: document.BlockNodeText, Text: "my_var"}}
-
- w := wire(t, UpdateText("target", content))
-
- assert.Equal(t, "update_text", w.Kind)
- assert.Equal(t, "target", w.BlockUID)
- assert.Nil(t, w.Block)
- assert.Equal(t, content, w.Content)
+ assert.Equal(t, Operation{Kind: "replace", BlockUID: "target", Block: &b}, Replace("target", paragraph()))
}
func Test_UpdateAttrs(t *testing.T) {
t.Parallel()
- w := wire(t, UpdateAttrs("target", map[string]any{"level": 3}))
-
- assert.Equal(t, "update_attrs", w.Kind)
- assert.Equal(t, "target", w.BlockUID)
- assert.Equal(t, 3, w.Attrs["level"])
+ assert.Equal(t, Operation{Kind: "update_attrs", BlockUID: "target", Attrs: map[string]any{"level": 3}}, UpdateAttrs("target", map[string]any{"level": 3}))
}
func Test_Delete(t *testing.T) {
t.Parallel()
- w := wire(t, Delete("target"))
-
- assert.Equal(t, "delete", w.Kind)
- assert.Equal(t, "target", w.BlockUID)
- assert.Nil(t, w.Block)
+ assert.Equal(t, Operation{Kind: "delete", BlockUID: "target"}, Delete("target"))
}
func Test_MoveAfter(t *testing.T) {
t.Parallel()
- w := wire(t, MoveAfter("target", "ref-uid"))
-
- assert.Equal(t, "move", w.Kind)
- assert.Equal(t, "after", w.Position)
- assert.Equal(t, "target", w.BlockUID)
- assert.Equal(t, "ref-uid", w.ReferenceUID)
- assert.Nil(t, w.Block)
+ assert.Equal(t, Operation{Kind: "move", Position: "after", BlockUID: "target", ReferenceUID: "ref"}, MoveAfter("target", "ref"))
}
func Test_MoveBefore(t *testing.T) {
t.Parallel()
- w := wire(t, MoveBefore("target", "ref-uid"))
-
- assert.Equal(t, "move", w.Kind)
- assert.Equal(t, "before", w.Position)
- assert.Equal(t, "target", w.BlockUID)
- assert.Equal(t, "ref-uid", w.ReferenceUID)
-}
-
-func Test_move(t *testing.T) {
- t.Parallel()
-
- w := wire(t, move("before", "target", "ref-uid"))
-
- assert.Equal(t, "move", w.Kind)
- assert.Equal(t, "before", w.Position)
- assert.Equal(t, "target", w.BlockUID)
- assert.Equal(t, "ref-uid", w.ReferenceUID)
- assert.Nil(t, w.Block)
+ assert.Equal(t, Operation{Kind: "move", Position: "before", BlockUID: "target", ReferenceUID: "ref"}, MoveBefore("target", "ref"))
}
func Test_SetName(t *testing.T) {
t.Parallel()
- w := wire(t, SetName("New title"))
-
- assert.Equal(t, "set_name", w.Kind)
- assert.Equal(t, "New title", w.Name)
+ assert.Equal(t, Operation{Kind: "set_name", Name: "New title"}, SetName("New title"))
}
func Test_SetIcon(t *testing.T) {
t.Parallel()
- w := wire(t, SetIcon("lucide:file-text"))
-
- assert.Equal(t, "set_icon", w.Kind)
- assert.Equal(t, "lucide:file-text", w.Icon)
+ assert.Equal(t, Operation{Kind: "set_icon", Icon: "lucide:file-text"}, SetIcon("lucide:file-text"))
}
diff --git a/server/core/internal/assistant/manager_test.go b/server/core/internal/assistant/manager_test.go
index fb47fef7..ab87abe6 100644
--- a/server/core/internal/assistant/manager_test.go
+++ b/server/core/internal/assistant/manager_test.go
@@ -357,11 +357,11 @@ func Test_Manager_ToolSet(t *testing.T) {
s := m.ToolSet("org1", "user1")
require.NotNil(t, s)
- // the set carries the full registry — the thirteen document tools,
- // the seven tag tools, the five hook tools, the nine data-source
+ // the set carries the full registry — the ten document tools,
+ // the five tag tools, the four hook tools, the five data-source
// tools and the offloaded-result reader — wired from the manager's
// shared dependencies and scoped to the requested pair.
- assert.Len(t, s.Tools(), 35)
+ assert.Len(t, s.Tools(), 25)
}
func Test_Manager_claimTurn(t *testing.T) {
diff --git a/server/core/internal/assistant/markup/build.go b/server/core/internal/assistant/markup/build.go
new file mode 100644
index 00000000..61bf97f8
--- /dev/null
+++ b/server/core/internal/assistant/markup/build.go
@@ -0,0 +1,540 @@
+package markup
+
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "maps"
+ "strconv"
+ "strings"
+
+ "github.com/oxynote/oxynote/server/core/internal/document"
+ "github.com/oxynote/oxynote/server/core/pkg/strutil"
+)
+
+// _defaultCalloutIcon is the icon a callout written without one gets,
+// as in the editor.
+const _defaultCalloutIcon = "lucide:text"
+
+// _maxBlocks caps the blocks one write may hold at the top level, each
+// of which is one operation for the realtime service.
+const _maxBlocks = 500
+
+// _containers are the elements that only hold blocks, each with the
+// node it writes and the one element it holds, if it holds only one.
+var _containers = map[string]struct {
+ // Type is the node the element writes.
+ Type document.BlockNodeType
+
+ // Child is the one element it holds; empty when it holds any block.
+ Child string
+}{
+ _elemBlockquote: {document.BlockNodeBlockquote, ""},
+ _elemCallout: {document.BlockNodeCalloutBlock, ""},
+ _elemBullets: {document.BlockNodeBulletList, _elemEntry},
+ _elemOrdered: {document.BlockNodeOrderedList, _elemEntry},
+ _elemTasks: {document.BlockNodeTaskList, _elemTask},
+ _elemMetrics: {document.BlockNodeMetricGrid, _elemMetric},
+}
+
+// _atoms are the elements that hold nothing but attributes: the node
+// each writes, its string attributes and its positive integer ones.
+var _atoms = map[string]struct {
+ // Type is the node the element writes.
+ Type document.BlockNodeType
+
+ // Strings are its string attributes.
+ Strings []string
+
+ // Ints are its positive integer attributes.
+ Ints []string
+}{
+ _elemImage: {document.BlockNodeImageBlock, []string{document.AttrSrc, document.AttrAlt, document.AttrTitle}, []string{document.AttrWidth}},
+ _elemFigma: {document.BlockNodeFigmaBlock, []string{document.AttrSrc}, []string{document.AttrWidth, document.AttrHeight}},
+}
+
+// Build turns markup into the blocks it describes, to be written into
+// stored. An element whose id stored holds replaces that block: it keeps
+// the block's uid and block comment, and is the stored block itself when
+// its visible content is unchanged, so what the markup does not show
+// survives. Every other element gets a new uid.
+func Build(markup string, stored document.RootBlock) ([]document.Block, error) {
+ nodes, err := parse(markup)
+ if err != nil {
+ return nil, err
+ }
+
+ b := builder{stored: stored, used: map[string]bool{}}
+
+ blocks, err := b.blocks(nodes, "the markup")
+ if err != nil {
+ return nil, err
+ }
+
+ if len(blocks) == 0 {
+ return nil, errors.New("the markup holds no block; to remove one, use delete_block")
+ }
+
+ if len(blocks) > _maxBlocks {
+ return nil, fmt.Errorf("%d blocks are over the limit of %d per call; send them in several calls", len(blocks), _maxBlocks)
+ }
+
+ // an element may name a block that another element in the markup
+ // also keeps as a hidden part, such as a list entry's paragraph.
+ if uid, found := findDuplicateUID(blocks, map[string]bool{}); found {
+ return nil, fmt.Errorf("id %q is kept twice: by its own element and as part of another one; leave it out of one of them", uid)
+ }
+
+ return blocks, nil
+}
+
+// builder turns parsed elements into blocks.
+type builder struct {
+ // stored is the document being written into.
+ stored document.RootBlock
+
+ // used holds the ids the markup's elements have named, since one id
+ // names one block.
+ used map[string]bool
+}
+
+// blocks builds an element per block. where names the container for
+// the message about text between the elements.
+func (b *builder) blocks(nodes []node, where string) ([]document.Block, error) {
+ elems, err := elements(nodes, where, "an element such as "}`,
+ },
+ "Stray angle bracket and unknown tag are text": {
+ Input: `
",
+ Result: "\n
\n",
+ },
+ "Line breaks around raw text are layout": {
+ Markup: "\nx\n
",
+ Result: "x
\n",
+ },
+ "Malformed markup": {Markup: "a b
", Err: errors.New("line 1: a heading takes plain text, so is not allowed in it")},
+ "Block inside text": {Markup: "
is a block; it cannot sit inside text")},
+ "Link to a script": {Markup: ``, Err: errors.New("line 1: a link cannot use javascript:; link to a web address instead")},
+ // links a person stored may use any other scheme, or not even
+ // parse, and a write that keeps them has to go through.
+ "Links of other schemes stay": {
+ Markup: ``,
+ Result: `` + "\n",
+ },
+ "Entry text ending in a space keeps it before nested blocks": {
+ Markup: "
",
+ Result: "
\n\n
\n",
+ },
+ "List of the wrong entries": {Markup: "\n
\n
", Err: errors.New("line 1: holds only
`, Err: errors.New("line 1:
width has to be a positive whole number")},
+ "Metric that is not JSON": {Markup: "
", _maxBlocks+1),
+ Err: errors.New("501 blocks are over the limit of 500 per call; send them in several calls"),
+ },
+ "Metric with a bad value": {Markup: `
",
+ Err: errors.New("line 1: text in
c
\n",
+ Result: `p@1["a"] hr@2`,
+ },
+ "Nothing at all": {},
+ "Text that is not blank": {
+ Markup: "",
+ document.BlockNodeBulletList: "
",
+ document.BlockNodeOrderedList: "
",
+ document.BlockNodeListItem: "
",
+ document.BlockNodeTitledCodeBlock: "a titled
",
+ document.BlockNodeCodeBlockTitle: "a titled
's title",
+ document.BlockNodeMermaidBlock: "
`,
+ Result: `pre@1(language=go){"if a < b && c { return \"
",
+ document.BlockNodeImageBlock: "",
+ document.BlockNodeFileBlock: "
a",
+ },
+ "Nodes that are not text are skipped": {
+ Content: []document.Block{{Type: document.BlockNodeParagraph}, text("a")},
+ Expected: "a",
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Expected, renderInline(c.Content))
+ })
+ }
+}
+
+func Test_visibleMarks(t *testing.T) {
+ t.Parallel()
+
+ got := visibleMarks([]document.Mark{
+ {Type: "comment"},
+ {Type: "italic"},
+ {Type: "link", Attrs: document.Attributes{_attrHref: `https://x.test/?a=1&b="2"`}},
+ })
+
+ assert.Equal(t, []tagMark{
+ {tag: _tagLink, open: ``},
+ {tag: _tagItalic, open: ""},
+ }, got)
+ assert.Nil(t, visibleMarks(nil))
+}
diff --git a/server/core/internal/assistant/markup/markup.go b/server/core/internal/assistant/markup/markup.go
new file mode 100644
index 00000000..9c1c4b28
--- /dev/null
+++ b/server/core/internal/assistant/markup/markup.go
@@ -0,0 +1,113 @@
+// Package markup renders documents as the XML the assistant reads and
+// writes, and builds ProseMirror blocks back from it.
+//
+// Each block is one element carrying its uid as id. Inline formatting is
+// tags (b, i, u, s, code, a href); pre, mermaid and metric hold raw text.
+// Parts the editor needs but nobody addresses, such as a list entry's
+// paragraph or a split_doc's sides, have no id of their own and keep
+// their stored uids through the element they belong to.
+package markup
+
+import (
+ "regexp"
+
+ "github.com/oxynote/oxynote/server/core/internal/document"
+)
+
+// Element names, as the model reads and writes them.
+const (
+ _elemParagraph = "p"
+ _elemBlockquote = "blockquote"
+ _elemBullets = "ul"
+ _elemOrdered = "ol"
+ _elemEntry = "li"
+ _elemTasks = "tasks"
+ _elemTask = "task"
+ _elemCallout = "callout"
+ _elemCode = "pre"
+ _elemMermaid = "mermaid"
+ _elemRule = "hr"
+ _elemImage = "img"
+ _elemFigma = "figma"
+ _elemFile = "file"
+ _elemMetrics = "metrics"
+ _elemMetric = "metric"
+ _elemSplitDoc = "split_doc"
+ _elemLeft = "left"
+ _elemRight = "right"
+ _elemParams = "params"
+ _elemParam = "param"
+
+ // _elemUnsupported stands in for a node type this package does not
+ // know, so a document holding one still reads. It can be kept but not
+ // written.
+ _elemUnsupported = "unsupported"
+)
+
+// Attribute names that are not ProseMirror attribute names.
+const (
+ // _attrID carries a block's uid.
+ _attrID = "id"
+
+ // _attrHeader is a parameter list's header text.
+ _attrHeader = "header"
+
+ // _attrType is a parameter's type, or an unsupported node's type.
+ _attrType = "type"
+
+ // _attrHref is a link's target.
+ _attrHref = "href"
+)
+
+// Inline tags, by the ProseMirror mark they write.
+const (
+ _tagBold = "b"
+ _tagItalic = "i"
+ _tagUnderline = "u"
+ _tagStrike = "s"
+ _tagCode = "code"
+ _tagLink = "a"
+)
+
+// _tagMarks maps each inline tag to the ProseMirror mark it writes.
+// Marks not listed, such as comments, stay hidden and are kept by the
+// build step.
+var _tagMarks = map[string]string{
+ _tagLink: "link",
+ _tagBold: "bold",
+ _tagItalic: "italic",
+ _tagUnderline: "underline",
+ _tagStrike: "strike",
+ _tagCode: "code",
+}
+
+// _tagNesting is the order inline tags nest in when several open
+// together.
+var _tagNesting = []string{_tagLink, _tagBold, _tagItalic, _tagUnderline, _tagStrike, _tagCode}
+
+// _rawElements hold raw text, up to their closing tag. Each maps to a
+// match of that closing tag escaped once or more in the text, capturing
+// the extra amp; levels and the name as written. A closing tag reads in
+// any case, so the name matches in any case too.
+var _rawElements = map[string]*regexp.Regexp{
+ _elemCode: regexp.MustCompile(`&((?:amp;)*)lt;/((?i:` + _elemCode + `))`),
+ _elemMermaid: regexp.MustCompile(`&((?:amp;)*)lt;/((?i:` + _elemMermaid + `))`),
+ _elemMetric: regexp.MustCompile(`&((?:amp;)*)lt;/((?i:` + _elemMetric + `))`),
+}
+
+// _rawCloseTags match the start of a raw element's closing tag in any
+// case, which is what its text has to escape.
+var _rawCloseTags = map[string]*regexp.Regexp{
+ _elemCode: regexp.MustCompile(`((?i:` + _elemCode + `))`),
+ _elemMermaid: regexp.MustCompile(`((?i:` + _elemMermaid + `))`),
+ _elemMetric: regexp.MustCompile(`((?i:` + _elemMetric + `))`),
+}
+
+// _hiddenMetricAttrs are metric attributes the model never sees: the
+// uid, which is the id, the block comment, and the simulation flag,
+// which core derives.
+var _hiddenMetricAttrs = map[string]bool{
+ document.AttrUID: true,
+ document.AttrCommentID: true,
+ document.AttrSimulationActive: true,
+}
diff --git a/server/core/internal/assistant/markup/metric.go b/server/core/internal/assistant/markup/metric.go
new file mode 100644
index 00000000..146d1ab5
--- /dev/null
+++ b/server/core/internal/assistant/markup/metric.go
@@ -0,0 +1,215 @@
+package markup
+
+import (
+ jsonv2 "encoding/json/v2"
+ "fmt"
+ "maps"
+ "slices"
+ "strings"
+
+ "github.com/oxynote/oxynote/server/core/internal/document"
+)
+
+// Metric enum values. They mirror the editor's enums in
+// web/app/components/editor/blocks/metrics/utils.ts (TimeRangePreset,
+// RefreshInterval, the Visualization*Unit enums, MetricBlockWidth,
+// MetricSimulationPreset) and web/app/utils/api/data-source/generic-query.ts
+// (GenericQueryChartType); a value added there has to be added here or the
+// assistant cannot author it.
+var (
+ // _metricVisualizationTypes are the chart kinds; they equal the
+ // datasource processor's ChartType values, which only the tests
+ // import, so this package stays free of the processors.
+ _metricVisualizationTypes = []string{"line_chart", "bar_chart", "gauge_chart"}
+
+ // _metricTimeRanges are the time-window presets.
+ _metricTimeRanges = []string{
+ "last_5_minutes", "last_15_minutes", "last_30_minutes",
+ "last_1_hour", "last_3_hours", "last_6_hours", "last_12_hours", "last_24_hours",
+ "last_2_days", "last_7_days", "last_30_days", "last_90_days",
+ "last_6_months", "last_1_year", "last_2_years", "last_5_years",
+ "today", "yesterday", "today_so_far",
+ "this_week", "this_week_so_far", "this_month", "this_month_so_far",
+ "this_year", "this_year_so_far",
+ "previous_week", "previous_month", "previous_year",
+ }
+
+ // _metricRefreshIntervals are the re-query periods.
+ _metricRefreshIntervals = []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"}
+
+ // _metricUnitTypes are the value units: custom, time, data and percent.
+ _metricUnitTypes = []string{
+ "custom",
+ "nanoseconds", "microseconds", "milliseconds", "seconds", "minutes", "hours", "days",
+ "bytes", "kilobytes", "megabytes", "gigabytes", "terabytes",
+ "bits", "kilobits", "megabits", "gigabits", "terabits",
+ "percent0to100", "percent0to1",
+ }
+
+ // _metricWidths are the block widths inside a metric_grid.
+ _metricWidths = []string{"compact", "standard", "wide"}
+
+ // _metricSimulationPresets are the generated series a block draws
+ // while the metric it documents has no real data to answer with.
+ _metricSimulationPresets = []string{
+ "cpu_usage", "memory_usage", "disk_usage",
+ "http_requests", "http_latency", "error_rate",
+ }
+
+ // _metricEnums maps each enum-valued attribute to its values, which
+ // is the table decodeMetricAttrs checks against and MetricEnums
+ // publishes.
+ _metricEnums = map[string][]string{
+ document.AttrVisualizationType: _metricVisualizationTypes,
+ document.AttrTimeRange: _metricTimeRanges,
+ document.AttrRefreshInterval: _metricRefreshIntervals,
+ document.AttrUnitType: _metricUnitTypes,
+ document.AttrWidth: _metricWidths,
+ document.AttrSimulationPreset: _metricSimulationPresets,
+ }
+)
+
+// MetricEnums returns every enum-valued metric attribute with its
+// allowed values, keyed by attribute name.
+func MetricEnums() map[string][]string {
+ out := make(map[string][]string, len(_metricEnums))
+
+ for k, v := range _metricEnums {
+ out[k] = slices.Clone(v)
+ }
+
+ return out
+}
+
+// MetricReference describes the attributes a
.
+ selfClosing bool
+}
+
+// parse reads markup into its top-level nodes.
+func parse(s string) ([]node, error) {
+ if len(s) > _maxMarkupLength {
+ return nil, fmt.Errorf("the markup is %d bytes, over the limit of %d; send it in several calls", len(s), _maxMarkupLength)
+ }
+
+ p := parser{s: s}
+
+ return p.nodes("", 0)
+}
+
+// parser walks markup once, front to back.
+type parser struct {
+ // s is the markup.
+ s string
+
+ // i is the position of the next unread byte.
+ i int
+
+ // counted is the position line has counted newlines up to, and
+ // lines the newlines before it. p.i only moves forward, so counting
+ // on from there keeps the parse linear.
+ counted, lines int
+
+ // depth is the number of elements open at p.i.
+ depth int
+}
+
+// nodes reads nodes up to the closing tag of the element named
+// closeName, opened on line opened, or to the end when closeName is
+// empty.
+func (p *parser) nodes(closeName string, opened int) ([]node, error) {
+ var out []node
+
+ for p.i < len(p.s) {
+ if name, end, ok := p.closeTag(); ok {
+ if name != closeName {
+ expected := "nothing"
+ if closeName != "" {
+ expected = endTag(closeName)
+ }
+
+ return nil, fmt.Errorf("line %d: %s> closes nothing open; expected %s", p.line(), name, expected)
+ }
+
+ p.i = end
+
+ return out, nil
+ }
+
+ if t, ok := p.openTag(); ok {
+ el, err := p.element(t)
+ if err != nil {
+ return nil, err
+ }
+
+ out = append(out, el)
+
+ continue
+ }
+
+ out = append(out, p.text())
+ }
+
+ if closeName != "" {
+ return nil, fmt.Errorf("line %d: <%s> is never closed", opened, closeName)
+ }
+
+ return out, nil
+}
+
+// element reads the element whose opening tag t starts at p.i.
+func (p *parser) element(t tag) (node, error) {
+ el := node{name: t.name, attrs: t.attrs, line: p.line()}
+ p.i = t.end
+
+ if t.selfClosing {
+ return el, nil
+ }
+
+ if _, raw := _rawElements[t.name]; raw {
+ start := p.i
+
+ for {
+ k := strings.Index(p.s[p.i:], "")
+ if k < 0 {
+ return node{}, fmt.Errorf("line %d: <%s> is never closed", el.line, t.name)
+ }
+
+ p.i += k
+
+ if name, end, ok := p.closeTag(); ok && name == t.name {
+ // a line break right after the opening tag or right
+ // before the closing one is layout, as after in
+ // HTML.
+ el.text = strings.TrimSuffix(strings.TrimPrefix(unescapeRaw(p.s[start:p.i], t.name), "\n"), "\n")
+ p.i = end
+
+ return el, nil
+ }
+
+ p.i += len("")
+ }
+ }
+
+ if p.depth == _maxDepth {
+ return node{}, fmt.Errorf("line %d: elements nest more than %d deep", el.line, _maxDepth)
+ }
+
+ p.depth++
+
+ children, err := p.nodes(t.name, el.line)
+ if err != nil {
+ return node{}, err
+ }
+
+ p.depth--
+ el.children = children
+
+ return el, nil
+}
+
+// text reads text up to the next known tag, decoding entities. A < that
+// starts no known tag is part of the text.
+func (p *parser) text() node {
+ start, line := p.i, p.line()
+
+ // the first byte is text: nodes calls this only where no known tag
+ // starts.
+ p.i++
+
+ for {
+ k := strings.IndexByte(p.s[p.i:], '<')
+ if k < 0 {
+ p.i = len(p.s)
+
+ break
+ }
+
+ p.i += k
+
+ if _, _, ok := p.closeTag(); ok {
+ break
+ }
+
+ if _, ok := p.openTag(); ok {
+ break
+ }
+
+ p.i++
+ }
+
+ return node{text: _entityDecoder.Replace(p.s[start:p.i]), line: line}
+}
+
+// openTag reads the opening tag of a known element at p.i without moving
+// past it. ok is false when none starts there.
+func (p *parser) openTag() (tag, bool) {
+ if p.s[p.i] != '<' {
+ return tag{}, false
+ }
+
+ name, i := p.tagName(p.i + len("<"))
+ if !_knownTags[name] {
+ return tag{}, false
+ }
+
+ t := tag{name: name, attrs: map[string]string{}}
+
+ for {
+ i = p.skipSpace(i)
+
+ switch {
+ case i >= len(p.s):
+ return tag{}, false
+ case p.s[i] == '>':
+ t.end = i + len(">")
+
+ return t, true
+ case strings.HasPrefix(p.s[i:], "/>"):
+ t.end = i + len("/>")
+ t.selfClosing = true
+
+ return t, true
+ }
+
+ key, value, next, ok := p.attribute(i)
+ if !ok {
+ return tag{}, false
+ }
+
+ t.attrs[key] = value
+ i = next
+ }
+}
+
+// attribute reads the attribute at i: its key, its decoded value and the
+// position after it. A bare attribute, such as checked, is true.
+func (p *parser) attribute(i int) (string, string, int, bool) {
+ start := i
+ i = p.nameEnd(i)
+
+ if i == start {
+ return "", "", 0, false
+ }
+
+ key := p.s[start:i]
+
+ if i >= len(p.s) || p.s[i] != '=' {
+ return key, "true", i, true
+ }
+
+ i++
+
+ if i >= len(p.s) || (p.s[i] != '"' && p.s[i] != '\'') {
+ return "", "", 0, false
+ }
+
+ k := strings.IndexByte(p.s[i+1:], p.s[i])
+ if k < 0 {
+ return "", "", 0, false
+ }
+
+ // the value, and the quotes either side of it.
+ return key, _entityDecoder.Replace(p.s[i+1 : i+1+k]), i + k + len(`""`), true
+}
+
+// closeTag reads the closing tag of a known element at p.i without
+// moving past it: its name and the position after it.
+func (p *parser) closeTag() (string, int, bool) {
+ if !strings.HasPrefix(p.s[p.i:], "") {
+ return "", 0, false
+ }
+
+ name, i := p.tagName(p.i + len(""))
+ i = p.skipSpace(i)
+
+ if !_knownTags[name] || i >= len(p.s) || p.s[i] != '>' {
+ return "", 0, false
+ }
+
+ return name, i + len(">"), true
+}
+
+// tagName reads the element name at i, lowercased and with any alias
+// resolved, and returns it with the position after it.
+func (p *parser) tagName(i int) (string, int) {
+ end := p.nameEnd(i)
+
+ name := strings.ToLower(p.s[i:end])
+ if alias, ok := _tagAliases[name]; ok {
+ name = alias
+ }
+
+ return name, end
+}
+
+// nameEnd returns the position after the element or attribute name at
+// i.
+func (p *parser) nameEnd(i int) int {
+ return len(p.s) - len(strings.TrimLeft(p.s[i:], _nameBytes))
+}
+
+// skipSpace returns the position of the first byte from i on that is
+// not white space.
+func (p *parser) skipSpace(i int) int {
+ return len(p.s) - len(strings.TrimLeft(p.s[i:], " \t\n\r"))
+}
+
+// line returns the line of p.i, counting from 1.
+func (p *parser) line() int {
+ p.lines += strings.Count(p.s[p.counted:p.i], "\n")
+ p.counted = p.i
+
+ return p.lines + 1
+}
+
+// unescapeRaw undoes escapeRaw: an escaped closing tag reads as the tag,
+// and one with more amp; loses one.
+func unescapeRaw(s, name string) string {
+ return _rawElements[name].ReplaceAllStringFunc(s, func(m string) string {
+ if tagName, ok := strings.CutPrefix(m, "</"); ok {
+ return "" + tagName
+ }
+
+ return strings.Replace(m, "amp;", "", 1)
+ })
+}
diff --git a/server/core/internal/assistant/markup/parse_test.go b/server/core/internal/assistant/markup/parse_test.go
new file mode 100644
index 00000000..1634353c
--- /dev/null
+++ b/server/core/internal/assistant/markup/parse_test.go
@@ -0,0 +1,174 @@
+package markup
+
+import (
+ "errors"
+ "maps"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/oxynote/oxynote/server/core/pkg/testutil"
+ "github.com/stretchr/testify/assert"
+)
+
+// dump writes parsed nodes in a compact form that tests compare
+// against: text as a quoted string, an element as name@line, its sorted
+// attributes in parentheses and its content in brackets.
+func dump(nodes []node) string {
+ parts := make([]string, 0, len(nodes))
+
+ for _, n := range nodes {
+ if n.name == "" {
+ parts = append(parts, strconv.Quote(n.text))
+
+ continue
+ }
+
+ var sb strings.Builder
+
+ sb.WriteString(n.name + "@" + strconv.Itoa(n.line))
+
+ if len(n.attrs) > 0 {
+ kv := make([]string, 0, len(n.attrs))
+ for _, k := range slices.Sorted(maps.Keys(n.attrs)) {
+ kv = append(kv, k+"="+n.attrs[k])
+ }
+
+ sb.WriteString("(" + strings.Join(kv, " ") + ")")
+ }
+
+ if n.text != "" {
+ sb.WriteString("{" + strconv.Quote(n.text) + "}")
+ }
+
+ if len(n.children) > 0 {
+ sb.WriteString("[" + dump(n.children) + "]")
+ }
+
+ parts = append(parts, sb.String())
+ }
+
+ return strings.Join(parts, " ")
+}
+
+func Test_parse(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Input string
+ Result string
+
+ // Count, when set, is the number of top-level nodes expected in
+ // place of Result.
+ Count int
+
+ Err error
+ }{
+ "Paragraph with inline tags": {
+ Input: `\n
\n
",
+ Result: `ul@1(id=l)["\n " li@2(id=a)["One"] "\n"] "\n" hr@4`,
+ },
+ "Raw element keeps markup and entities as written": {
+ Input: `if a < b && c { return "
and
and z
`,
+ Result: `task@1(checked=true id=t"1)["Done"] img@1(src=x.png width=2)`,
+ },
+ "Malformed tag is text": {
+ Input: `a
\nc
",
+ Result: `pre@1{"a"} "\n" p@2["b"] "\n" pre@3{"c"}`,
+ },
+ "Raw element runs past a closing tag of another element": {
+ Input: "a
x",
+ Err: errors.New("line 1: is never closed"),
+ },
+ "Closing tag of another element is refused": {
+ Input: "a\n",
+ Err: errors.New("line 2: closes nothing open; expected
"),
+ },
+ "Closing tag with nothing open is refused": {
+ Input: "a",
+ Err: errors.New("line 1: closes nothing open; expected nothing"),
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := parse(c.Input)
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ if c.Count > 0 {
+ assert.Len(t, got, c.Count)
+
+ return
+ }
+
+ assert.Equal(t, c.Result, dump(got))
+ })
+ }
+}
+
+func Test_unescapeRaw(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "a </pre> </pre> </p>", unescapeRaw("a </pre> </pre> &lt;/pre> </p>", _elemCode))
+ assert.Equal(t, "a </Pre>", unescapeRaw("a </PRE> </Pre>", _elemCode))
+}
diff --git a/server/core/internal/assistant/markup/render.go b/server/core/internal/assistant/markup/render.go
new file mode 100644
index 00000000..bb5abc2c
--- /dev/null
+++ b/server/core/internal/assistant/markup/render.go
@@ -0,0 +1,297 @@
+package markup
+
+import (
+ jsonv2 "encoding/json/v2"
+ "strconv"
+ "strings"
+
+ "github.com/oxynote/oxynote/server/core/internal/document"
+)
+
+// _indent is one level of nesting in rendered markup.
+const _indent = " "
+
+// _plainContainers names the elements of the nodes that hold blocks and
+// carry no attribute but their id.
+var _plainContainers = map[document.BlockNodeType]string{
+ document.BlockNodeBlockquote: _elemBlockquote,
+ document.BlockNodeBulletList: _elemBullets,
+ document.BlockNodeTaskList: _elemTasks,
+ document.BlockNodeMetricGrid: _elemMetrics,
+}
+
+// _textEscaper escapes the characters that would start markup in text.
+var _textEscaper = strings.NewReplacer("&", "&", "<", "<")
+
+// _attrEscaper escapes the characters that would end or start markup in
+// an attribute value.
+var _attrEscaper = strings.NewReplacer("&", "&", "<", "<", `"`, """)
+
+// Render returns blocks as markup: one element per block, nested
+// elements indented one level deeper than their parent.
+func Render(blocks []document.Block) string {
+ var r renderer
+
+ for _, b := range blocks {
+ r.block(b, 0)
+ }
+
+ return r.sb.String()
+}
+
+// renderer accumulates rendered markup.
+type renderer struct {
+ // sb holds the markup written so far.
+ sb strings.Builder
+}
+
+// block renders one block and everything nested in it.
+func (r *renderer) block(b document.Block, depth int) {
+ if name, ok := _plainContainers[b.Type]; ok {
+ r.container(name, startTag(name, b), b.Content, depth)
+
+ return
+ }
+
+ switch b.Type {
+ case document.BlockNodeParagraph:
+ r.line(depth, startTag(_elemParagraph, b)+renderInline(b.Content)+endTag(_elemParagraph))
+ case document.BlockNodeHeading:
+ name := "h" + strconv.Itoa(max(1, b.Attrs.Get(document.AttrLevel).Int()))
+ r.line(depth, startTag(name, b)+_textEscaper.Replace(b.PlainText())+endTag(name))
+ case document.BlockNodeCalloutBlock:
+ r.container(_elemCallout, startTag(_elemCallout, b, blockAttr(b, document.AttrIcon)), b.Content, depth)
+ case document.BlockNodeOrderedList:
+ start := attr{key: document.AttrStart}
+ if n := b.Attrs.Get(document.AttrStart).Int(); n > 1 {
+ start.value = n
+ }
+
+ r.container(_elemOrdered, startTag(_elemOrdered, b, start), b.Content, depth)
+ case document.BlockNodeListItem:
+ r.entry(_elemEntry, startTag(_elemEntry, b), b, depth)
+ case document.BlockNodeTaskItem:
+ r.entry(_elemTask, startTag(_elemTask, b, blockAttr(b, document.AttrChecked)), b, depth)
+ case document.BlockNodeCodeBlock:
+ r.raw(depth, startTag(_elemCode, b, blockAttr(b, document.AttrLanguage)), _elemCode, b.PlainText())
+ case document.BlockNodeTitledCodeBlock:
+ code := b.FirstChild(document.BlockNodeCodeBlock)
+
+ // an empty title still renders, since a pre without one is plain
+ // code, which is another block.
+ title := attr{key: document.AttrTitle, value: b.FirstChild(document.BlockNodeCodeBlockTitle).PlainText(), keepEmpty: true}
+ r.raw(depth, startTag(_elemCode, b, title, blockAttr(code, document.AttrLanguage)), _elemCode, code.PlainText())
+ case document.BlockNodeMermaidBlock:
+ r.raw(depth, startTag(_elemMermaid, b), _elemMermaid, b.PlainText())
+ case document.BlockNodeHorizontalRule:
+ r.line(depth, emptyTag(_elemRule, b))
+ case document.BlockNodeImageBlock:
+ r.line(depth, emptyTag(_elemImage, b,
+ blockAttr(b, document.AttrSrc),
+ blockAttr(b, document.AttrAlt),
+ blockAttr(b, document.AttrTitle),
+ blockAttr(b, document.AttrWidth),
+ ))
+ case document.BlockNodeFigmaBlock:
+ r.line(depth, emptyTag(_elemFigma, b,
+ blockAttr(b, document.AttrSrc),
+ blockAttr(b, document.AttrWidth),
+ blockAttr(b, document.AttrHeight),
+ ))
+ case document.BlockNodeFileBlock:
+ r.line(depth, emptyTag(_elemFile, b,
+ blockAttr(b, document.AttrSrc),
+ blockAttr(b, document.AttrName),
+ blockAttr(b, document.AttrContentType),
+ blockAttr(b, document.AttrSize),
+ ))
+ case document.BlockNodeMetricBlock:
+ r.raw(depth, startTag(_elemMetric, b), _elemMetric, metricJSON(b))
+ case document.BlockNodeSplitDoc:
+ r.container(_elemSplitDoc, startTag(_elemSplitDoc, b, blockAttr(b, document.AttrInversed)), b.Content, depth)
+ case document.BlockNodeSplitDocLeft:
+ r.container(_elemLeft, "<"+_elemLeft+">", b.Content, depth)
+ case document.BlockNodeSplitDocRight:
+ r.container(_elemRight, "<"+_elemRight+">", b.Content, depth)
+ case document.BlockNodeParamList:
+ header := attr{key: _attrHeader, value: b.FirstChild(document.BlockNodeParamListHeader).PlainText()}
+ r.container(_elemParams, startTag(_elemParams, b, header), b.Content, depth)
+ case document.BlockNodeParamListHeader:
+ // its text is the params' header attribute.
+ case document.BlockNodeParamListItem:
+ r.param(b, depth)
+ default:
+ r.line(depth, emptyTag(_elemUnsupported, b, attr{key: _attrType, value: string(b.Type)}))
+ }
+}
+
+// line writes one line of markup at the given depth.
+func (r *renderer) line(depth int, s string) {
+ r.sb.WriteString(strings.Repeat(_indent, depth))
+ r.sb.WriteString(s)
+ r.sb.WriteByte('\n')
+}
+
+// container writes an element whose content is blocks, each on its
+// own lines one level deeper.
+func (r *renderer) container(name, openTag string, children []document.Block, depth int) {
+ r.line(depth, openTag)
+
+ for _, c := range children {
+ r.block(c, depth+1)
+ }
+
+ r.line(depth, endTag(name))
+}
+
+// entry writes a list or task entry: the text of its leading paragraph,
+// then whatever is nested under it.
+func (r *renderer) entry(name, openTag string, b document.Block, depth int) {
+ children := b.Content
+
+ var text string
+
+ if len(children) > 0 && children[0].Type == document.BlockNodeParagraph {
+ text = renderInline(children[0].Content)
+ children = children[1:]
+ }
+
+ if len(children) == 0 {
+ r.line(depth, openTag+text+endTag(name))
+
+ return
+ }
+
+ r.container(name, openTag+text, children, depth)
+}
+
+// raw writes an element whose content is raw text, kept as it is but
+// for its own closing tag, which would end it early.
+func (r *renderer) raw(depth int, openTag, name, text string) {
+ // parsing drops one line break after the opening tag and one before
+ // the closing tag, so text that starts or ends with one gets another.
+ if strings.HasPrefix(text, "\n") {
+ text = "\n" + text
+ }
+
+ if strings.HasSuffix(text, "\n") {
+ text += "\n"
+ }
+
+ r.line(depth, openTag+escapeRaw(text, name)+endTag(name))
+}
+
+// param writes one parameter row with its name, type and description.
+func (r *renderer) param(row document.Block, depth int) {
+ header := row.FirstChild(document.BlockNodeParamListItemHeader)
+ name := attr{key: document.AttrName, value: header.FirstChild(document.BlockNodeParamListItemTitle).PlainText()}
+ typ := attr{key: _attrType, value: header.FirstChild(document.BlockNodeParamListItemType).PlainText()}
+ description := row.FirstChild(document.BlockNodeParagraph).Content
+
+ r.line(depth, startTag(_elemParam, row, name, typ)+renderInline(description)+endTag(_elemParam))
+}
+
+// metricJSON returns a metric's attributes as JSON, leaving out the
+// hidden ones and those without a value.
+func metricJSON(b document.Block) string {
+ attrs := make(map[string]any, len(b.Attrs))
+
+ for k, v := range b.Attrs {
+ if v != nil && !_hiddenMetricAttrs[k] {
+ attrs[k] = v
+ }
+ }
+
+ // keys are sorted so that the same attributes always render the
+ // same. json/v2 leaves the < and & of queries unescaped.
+ out, err := jsonv2.Marshal(attrs, jsonv2.Deterministic(true))
+ if err != nil {
+ // NOCOV: attributes decoded from JSON always encode back.
+ return "{}"
+ }
+
+ return string(out)
+}
+
+// attr is one attribute of a rendered element.
+type attr struct {
+ // key is the attribute name.
+ key string
+
+ // value is the attribute value: a string, a bool or a number.
+ value any
+
+ // keepEmpty indicates that the attribute renders even when empty.
+ keepEmpty bool
+}
+
+// text returns the value as written: a string as it is, a bool only
+// when true, a number only when positive. Any other value is "".
+func (a attr) text() string {
+ switch v := a.value.(type) {
+ case string:
+ return v
+ case bool:
+ if v {
+ return "true"
+ }
+ case int:
+ if v > 0 {
+ return strconv.Itoa(v)
+ }
+ case float64:
+ if n := int(v); n > 0 {
+ return strconv.Itoa(n)
+ }
+ }
+
+ return ""
+}
+
+// blockAttr returns b's attribute key as a rendered attribute.
+func blockAttr(b document.Block, key string) attr {
+ return attr{key: key, value: b.Attrs[key]}
+}
+
+// startTag returns the start tag of the element for b, its id first.
+func startTag(name string, b document.Block, attrs ...attr) string {
+ return "<" + name + renderAttrs(b, attrs) + ">"
+}
+
+// emptyTag returns the tag of an element for b that holds nothing.
+func emptyTag(name string, b document.Block, attrs ...attr) string {
+ return "<" + name + renderAttrs(b, attrs) + "/>"
+}
+
+// endTag returns the end tag of the named element.
+func endTag(name string) string {
+ return "" + name + ">"
+}
+
+// renderAttrs renders b's id and the given attributes. One without a
+// value is left out unless it is kept.
+func renderAttrs(b document.Block, attrs []attr) string {
+ var sb strings.Builder
+
+ if uid, _ := b.UID(); uid != "" {
+ sb.WriteString(` id="` + _attrEscaper.Replace(uid) + `"`)
+ }
+
+ for _, a := range attrs {
+ if text := a.text(); text != "" || a.keepEmpty {
+ sb.WriteString(" " + a.key + `="` + _attrEscaper.Replace(text) + `"`)
+ }
+ }
+
+ return sb.String()
+}
+
+// escapeRaw escapes the one sequence raw text cannot hold as is: its
+// element's closing tag, in any case. A closing tag the text already
+// holds escaped gets one more amp; so that reading it back undoes
+// exactly one level.
+func escapeRaw(s, name string) string {
+ s = _rawElements[name].ReplaceAllString(s, "&${1}lt;/${2}")
+
+ return _rawCloseTags[name].ReplaceAllString(s, "</${1}")
+}
diff --git a/server/core/internal/assistant/markup/render_test.go b/server/core/internal/assistant/markup/render_test.go
new file mode 100644
index 00000000..9b8ce672
--- /dev/null
+++ b/server/core/internal/assistant/markup/render_test.go
@@ -0,0 +1,135 @@
+package markup
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/oxynote/oxynote/server/core/internal/document"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+// _fixtures are the documents in testdata, each with a golden rendering
+// beside it.
+var _fixtures = []string{"all_blocks", "welcome", "scratch", "empty_parts"}
+
+// loadFixture reads a document fixture from testdata.
+func loadFixture(t *testing.T, name string) document.RootBlock {
+ t.Helper()
+
+ data, err := os.ReadFile("testdata/" + name + ".json") //nolint:gosec // the name is one of this package's own fixtures
+ require.NoError(t, err)
+
+ var root document.RootBlock
+ require.NoError(t, json.Unmarshal(data, &root))
+
+ return root
+}
+
+func Test_Render(t *testing.T) {
+ t.Parallel()
+
+ for _, name := range _fixtures {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ got := Render(loadFixture(t, name).Content)
+
+ // a rendering change is meant to be reviewed as a golden
+ // diff, so the file is rewritten on request.
+ if os.Getenv("UPDATE_GOLDEN") != "" {
+ require.NoError(t, os.WriteFile("testdata/"+name+".xml", []byte(got), 0o600))
+
+ return
+ }
+
+ want, err := os.ReadFile("testdata/" + name + ".xml") //nolint:gosec // the name is one of this package's own fixtures
+ require.NoError(t, err)
+ assert.Equal(t, string(want), got)
+ })
+ }
+}
+
+func Test_renderer_entry(t *testing.T) {
+ t.Parallel()
+
+ // an entry whose first child is not a paragraph has no text of its
+ // own, and its children render nested.
+ got := Render([]document.Block{{
+ Type: document.BlockNodeBulletList,
+ Attrs: document.Attributes{document.AttrUID: "l"},
+ Content: []document.Block{{
+ Type: document.BlockNodeListItem,
+ Attrs: document.Attributes{document.AttrUID: "li"},
+ Content: []document.Block{{
+ Type: document.BlockNodeCodeBlock,
+ Attrs: document.Attributes{document.AttrUID: "c"},
+ Content: []document.Block{{Type: document.BlockNodeText, Text: "x"}},
+ }},
+ }},
+ }})
+
+ assert.Equal(t, strings.Join([]string{
+ `x`, + `
Plain bold both under gone code <x> and a link with a comment & 1 < 2 > 0.
++Quoted
+
nested code+
Callout
+if a < b && c {
+ return "</pre>"
+}
+
+Issues a token.
+{"email": "a@b"}
+ Second pass with bold_marker and italic word and a link end
+my_var = "a\\b" +y := c\d **1**+
Does a thing
+curl -H "x_api_key: a\b" https://example.com/_v1+
++Quote p1
+Inserted after bold
+Quote p2
+
Set myvar_name, then foo_bar and “term” and plain and bold end.
+Recheck my_var_name, then foo_bar_ and “term” and plain and bold end.
+Underline foobarbaz, “quoted” and plain next to my_var_name.
+x := 1+
/ to add blocks (headings, code, checklists, etc.)|| for a split documentation block (clean, Stripe-style docs layout)%% for a live metrics block (connects to your Prometheus)\n Hi there\n
") + require.NoError(t, err) + + got, err := inline(nodes[0].children, nil) + require.NoError(t, err) + assert.Equal(t, []document.Block{ + {Type: document.BlockNodeText, Text: "Hi "}, + {Type: document.BlockNodeText, Text: "there", Marks: []document.Mark{{Type: "bold"}}}, + }, got) +} + +func Test_textRuns(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Markup string + Result []run + Err error + }{ + "Plain text": { + Markup: "a
", + Result: []run{{text: "a"}}, + }, + "Tags in any order give the same marks": { + Markup: ``, + Result: []run{{text: "a", marks: []document.Mark{{Type: "link", Attrs: document.Attributes{_attrHref: "x"}}, {Type: "italic"}}}}, + }, + "Block inside text": { + Markup: " and , and headings take plain text.
`
// _etiquetteSection codifies how edits are shaped. Shared verbatim
// between the chat prompt and the MCP instructions.
-const _etiquetteSection = `## Edit etiquette
+const _etiquetteSection = `## Editing
-Make small, targeted edits with the smallest tool that does the job, and send every independent edit of a turn together. Reorder with move_block: the block keeps its uid, and comments, hooks and files hang off that uid, so a copy inserted elsewhere loses them.
-
-Write each section completely before starting the next. A heading with nothing under it reads as an unfinished document, so add a heading when its content is ready.
-
-A finished document agrees with itself: names, numbers and claims match across sections. That is the standard the work is held to, so resolve any contradiction you notice before reporting.
+Make small, targeted edits and send independent ones together. Reorder with move_block, which keeps a block's id, rather than deleting and inserting. Write each section completely before the next, and keep names, numbers and claims consistent across the document.
`
// _aestheticsSection states what a readable document looks like.
// Shared verbatim between the chat prompt and the MCP instructions.
-const _aestheticsSection = `## Aesthetics
+const _aestheticsSection = `## Style
-Documents are for humans to read. Make them read well.
-
-- The document name is shown above the content, so open with the first real paragraph or section rather than a heading that repeats it.
-- split_doc draws its own dividers, so leave horizontal_rule out next to it.
-- For code and titled_code, write language-agnostic pseudo-code or a natural-language sketch unless the document names a stack, and leave language empty rather than guessing one.
-- A titled_code title names what the code is. For a request or response that is the method and route, such as POST /v1/assets/{id}/due-date/preview, so the body itself does not repeat it.
-- Use callout for a constraint, warning or gotcha the reader must not miss; ordinary emphasis belongs in prose.
-- Keep the outline flat: most documents need two or three sections, and a heading earns its place only when the section under it runs longer than a paragraph or two.
+Documents are for humans to read. Open with the first real paragraph, since the name already shows above the content. Keep the outline flat, with a heading only over more than a paragraph or two. Use a callout for what the reader must not miss. Leave a code language empty unless the document names a stack, and title a titled pre with what the code is, such as POST /v1/assets/{id}.
`
// _reminderSection closes the chat prompt with the rules that matter
// most, restated in two lines. Models weight the start and the end of
-// a prompt more than its middle, and the reference material above is
-// long, so the behaviour rules get a second showing here.
+// a prompt more than its middle, so the behaviour rules get a second
+// showing here.
const _reminderSection = `## Before you reply
Read before you write, put every related edit in this turn, ask at most one question, and keep the reply brief.
@@ -166,24 +83,22 @@ Read before you write, put every related edit in this turn, ask at most one ques
// assistant turn: the Rubber Duck persona and chat tool-use rules,
// the sections shared with the MCP instructions, and the closing
// reminder.
-const _basePrompt = _personaSection + _blockModelSection + _etiquetteSection + _aestheticsSection + _reminderSection
+const _basePrompt = _personaSection + _workflowSection + _blockModelSection + _etiquetteSection + _aestheticsSection + _reminderSection
// _mcpIntroSection frames the shared sections for an agent connecting
// over MCP: unlike the chat model, it knows nothing about Oxynote, and
// its own client owns the approval story, so writes apply immediately
// instead of waiting on a confirmation.
-const _mcpIntroSection = `You are connected to Oxynote, a collaborative product for writing technical documentation. The tools operate on the documents of one organisation, which are also listed as resources. Blocks are the unit of content, addressed by uid; comments, hooks and files hang off those uids. Documents are written for humans first, balancing prose with technical detail where it sharpens meaning.
-
-Read before you write: find the document, read it with get_document, and read a block in full only when you are about to edit its inner structure. Documents have branches, and every read or write of content names one by branch_id: list_documents carries each document's default_branch_id, a search hit carries the branch_id it was found on, and get_document lists every branch of a document with its id. Write tools apply immediately, and a protected branch is read-only, so write to another branch of the document. Tags label documents in the sidebar and attach to one branch of a document, so assign_tag and unassign_tag take a branch_id like the content tools; list_tags shows every tag with its id and the documents carrying it, and get_document shows the tags of the branch it reads. Hooks are freshness watchers on a branch or on one of its blocks; list_hooks shows them with their ids, and create_hook takes the branch_id like the content tools and a block_uid from get_document to anchor to a block. The data-source tools are read-only; discover metric, label and table names with the metadata tools instead of guessing them, and run a query with the chart_type you intend before writing a metric block, so you know it renders.
+const _mcpIntroSection = `You are connected to Oxynote, a collaborative product for writing technical documentation, with the documents of one organisation. Documents are written for humans first, balancing prose with technical detail where it sharpens meaning. Write tools apply immediately.
`
// MCPInstructions returns the text the MCP server hands a connecting
// client in its initialize response: the MCP framing followed by the
-// same block model, edit etiquette, and aesthetics sections the chat
-// prompt carries.
+// sections the chat prompt shares. Claude Code shows only the first 2 KB
+// of it, so it has to stay below that.
func MCPInstructions() string {
- return _mcpIntroSection + _blockModelSection + _etiquetteSection + _aestheticsSection
+ return _mcpIntroSection + _workflowSection + _blockModelSection + _etiquetteSection + _aestheticsSection
}
// buildSystemPrompt assembles the prompt sent on each turn. The
diff --git a/server/core/internal/assistant/prompt_test.go b/server/core/internal/assistant/prompt_test.go
index 8815909a..d51a666b 100644
--- a/server/core/internal/assistant/prompt_test.go
+++ b/server/core/internal/assistant/prompt_test.go
@@ -6,8 +6,6 @@ import (
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
- "github.com/oxynote/oxynote/server/core/internal/document"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -32,46 +30,9 @@ func Test_buildSystemPrompt(t *testing.T) {
assert.Contains(t, branched, "write with branch_id `draft`")
assertHouseStyle(t, branched)
- // documents have branches, and the prompt says so.
- assert.Contains(t, got, "Documents have branches")
- assert.Contains(t, got, "assign_tag and unassign_tag take a branch_id")
- assert.Contains(t, got, "create_hook takes the branch_id like the content tools")
-
- // width is the one metric enum the prompt is still the only source
- // for: attrs is a single field shared by every block type, and
- // width means something else on an image, so the tool schema cannot
- // publish it. The rest moved there, where Test_attrProps pins them.
- for _, v := range block.MetricEnums()[document.AttrWidth] {
- assert.Contains(t, got, v, "width value %q is missing from the prompt", v)
- }
-
- for _, attr := range []string{
- document.AttrDataSourceID,
- document.AttrVisualizationType,
- document.AttrQueries,
- document.AttrTimeRange,
- document.AttrRefreshInterval,
- document.AttrThresholds,
- document.AttrBaseThresholdColor,
- document.AttrDecimals,
- document.AttrUnitType,
- document.AttrUnitCustom,
- document.AttrAxisBoundsMin,
- document.AttrAxisBoundsMax,
- document.AttrSimulationPreset,
- } {
- assert.Contains(t, got, attr, "attr %q is missing from the prompt", attr)
- }
-
- // the model authors metric blocks now, so the prompt must not still
- // tell it not to.
- assert.NotContains(t, got, "do not author")
-
- // the block model is the only schema the chat model sees, so every
- // type the tools accept has to be in it.
- for _, bt := range block.Types() {
- assert.Contains(t, _blockModelSection, "| "+bt+" |", "block type %q is missing from the prompt", bt)
- }
+ // the model reads and writes content as markup, keeping ids.
+ assert.Contains(t, got, "replace_blocks, keeping the id of every element that stays")
+ assert.Contains(t, got, "branch_id")
// the rubber duck stance needs its counterweight: an explicit
// request for text is written, not interviewed about.
@@ -96,18 +57,18 @@ func Test_MCPInstructions(t *testing.T) {
got := MCPInstructions()
// the shared sections ship verbatim, so the two surfaces describe
- // the same block model, etiquette, and aesthetics.
+ // the same workflow, content model, etiquette and style.
+ assert.Contains(t, got, _workflowSection)
assert.Contains(t, got, _blockModelSection)
assert.Contains(t, got, _etiquetteSection)
assert.Contains(t, got, _aestheticsSection)
- // reordering is the etiquette rule the surface exists to teach.
- assert.Contains(t, got, "move_block")
+ // Claude Code shows the first 2 KB of a server's instructions and
+ // drops the rest.
+ assert.Less(t, len(got), 2000)
// an MCP client learns about branches and the protected rule here.
- assert.Contains(t, got, "Documents have branches")
- assert.Contains(t, got, "assign_tag and unassign_tag take a branch_id")
- assert.Contains(t, got, "create_hook takes the branch_id like the content tools")
+ assert.Contains(t, got, "names a branch by branch_id")
assert.Contains(t, got, "protected branch is read-only")
// the persona and its confirmation flow are the chat surface's;
diff --git a/server/core/internal/assistant/protocol/protocol.go b/server/core/internal/assistant/protocol/protocol.go
index 2ab567e5..7f8321c6 100644
--- a/server/core/internal/assistant/protocol/protocol.go
+++ b/server/core/internal/assistant/protocol/protocol.go
@@ -136,7 +136,7 @@ type ToolStatusMessage struct {
// Type is always ServerTypeToolStatus.
Type ServerMessageType `json:"type"`
- // Tool is the tool name (e.g. "create_document", "read_block").
+ // Tool is the tool name (e.g. "create_document", "get_document").
Tool string `json:"tool"`
// Label is the user-facing description (e.g. "Reading 'Cat
@@ -222,7 +222,7 @@ type ConfirmRequest struct {
// ConfirmAction summarises one write operation for the confirm UI.
type ConfirmAction struct {
// Tool is the name of the tool the assistant intends to call
- // (e.g. "insert_block", "delete_document"). The UI uses this
+ // (e.g. "insert_blocks", "delete_document"). The UI uses this
// to pick an affordance (e.g. emphasise deletes).
Tool string `json:"tool"`
diff --git a/server/core/internal/assistant/session_test.go b/server/core/internal/assistant/session_test.go
index 1cf0a468..c3d0e7ef 100644
--- a/server/core/internal/assistant/session_test.go
+++ b/server/core/internal/assistant/session_test.go
@@ -188,7 +188,7 @@ var (
func stubEditApplier() *toolsMock.EditApplier {
return &toolsMock.EditApplier{
ApplyFunc: func(_ context.Context, _, _ xid.ID, _ []edit.Operation, _ string, _ bool) (edit.Result, error) {
- return edit.Result{Applied: 1, Errors: []edit.OpError{}}, nil
+ return edit.Result{Errors: []edit.OpError{}}, nil
},
}
}
@@ -614,11 +614,11 @@ func Test_session_handleConfirmResponse(t *testing.T) {
t.Parallel()
docID := _stubDocID.String()
- edit1 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"a","text":"one"}`
- edit2 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"b","text":"two"}`
+ edit1 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"a","content":"one
"}`
+ edit2 := `{"document_id":"` + docID + `","branch_id":"` + _stubBranchID.String() + `","block_uid":"b","content":"two
"}`
oneWrite := []*schema.Message{
- toolCall("1", string(tools.NameUpdateBlockText), edit1),
+ toolCall("1", string(tools.NameReplaceBlocks), edit1),
schema.AssistantMessage("updated the intro", nil),
}
@@ -664,8 +664,8 @@ func Test_session_handleConfirmResponse(t *testing.T) {
// two separate write batches: the first is confirmed, the
// second must ride on the same answer.
Responses: []*schema.Message{
- toolCall("1", string(tools.NameUpdateBlockText), edit1),
- toolCall("2", string(tools.NameUpdateBlockText), edit2),
+ toolCall("1", string(tools.NameReplaceBlocks), edit1),
+ toolCall("2", string(tools.NameReplaceBlocks), edit2),
schema.AssistantMessage("both edits applied", nil),
},
Approved: true,
diff --git a/server/core/internal/assistant/tools/block.go b/server/core/internal/assistant/tools/block.go
index b0fc2f12..0bda49dd 100644
--- a/server/core/internal/assistant/tools/block.go
+++ b/server/core/internal/assistant/tools/block.go
@@ -3,37 +3,23 @@ package tools
import (
"errors"
"fmt"
- "maps"
- "slices"
- "strings"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
+ "github.com/oxynote/oxynote/server/core/internal/assistant/markup"
"github.com/oxynote/oxynote/server/core/internal/document"
- "github.com/oxynote/oxynote/server/core/pkg/errutil"
- "github.com/oxynote/oxynote/server/core/pkg/strutil"
"github.com/rs/xid"
)
-// _maxPreviewLen caps the quoted text preview shown in the confirm UI.
-const _maxPreviewLen = 60
-
-// _listEntries are the list wrappers read_block returns as their entry.
-var _listEntries = map[document.BlockNodeType]bool{
- document.BlockNodeListItem: true,
- document.BlockNodeTaskItem: true,
-}
-
-// _textHolders maps each block holding its text one level down to the
-// child the text lives in, matching the realtime service's update_text.
-// Every other text-bearing block holds it directly.
-var _textHolders = map[document.BlockNodeType]document.BlockNodeType{
- document.BlockNodeTitledCodeBlock: document.BlockNodeCodeBlock,
- document.BlockNodeCalloutBlock: document.BlockNodeParagraph,
- document.BlockNodeBlockquote: document.BlockNodeParagraph,
- document.BlockNodeListItem: document.BlockNodeParagraph,
- document.BlockNodeTaskItem: document.BlockNodeParagraph,
-}
+// _contentDescription describes the XML a block write takes. Only
+// insert_blocks carries it; the others refer to it there.
+var _contentDescription = "The blocks as XML, one element per block. " +
+ "Elements: ;
to , plain text; and of - , and
of , where an entry holds its text and then any blocks nested under it; " +
+ " and holding blocks; with raw code, or with a title the titled code a split_doc's right side takes; with raw source; " +
+ "
;
; ; of , which holds a JSON object of its attributes; " +
+ " holding , an then
\ntwo
`},
+ Ops: []string{`"kind":"append"`, `"kind":"insert","position":"after"`},
},
- "Text written on a named branch": {
- DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubBranchID) + `,"block_uid":"a","text":"hi"}`,
- Contains: []string{`"uid":"a"`, `"text":"hi"`},
+ "Prepended, the rest chained after the first": {
+ DB: stubContentDB(nil),
+ Args: `{` + targetArgs(_stubMainBranchID) + `,"position":"start","content":` + two + `}`,
+ Ops: []string{`"kind":"prepend"`, `"kind":"insert","position":"after"`},
},
- "Paragraph text is read as markdown": {
- DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","text":"say **hi**"}`,
- Contains: []string{`"text":"say hi"`},
- Op: `{"kind":"update_text","block_uid":"a","content":[{"type":"text","text":"say "},{"type":"text","text":"hi","marks":[{"type":"bold"}]}]}`,
+ "Inserted before a block": {
+ DB: stubContentDB(nil),
+ Args: `{` + targetArgs(_stubMainBranchID) + `,"position":"before","reference_block_uid":"a","content":` + two + `}`,
+ Ops: []string{`"position":"before","reference_uid":"a"`, `"position":"after"`},
},
- "Code text is sent raw": {
- DB: stubNestedDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"c","text":"a_b \\c **1**"}`,
- Contains: []string{`"uid":"c"`, `"text":"a_b \\c **1**"`},
- Op: `{"kind":"update_text","block_uid":"c","content":[{"type":"text","text":"a_b \\c **1**"}]}`,
- },
- "Titled code text lands in its code": {
- DB: stubNestedDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"tc","text":"new_code"}`,
- Contains: []string{`"kind":"titled_code"`, `"text":"GET /x new_code"`},
- Op: `{"kind":"update_text","block_uid":"tc","content":[{"type":"text","text":"new_code"}]}`,
- },
- "List entry keeps its nested list": {
- DB: stubNestedDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"li","text":"uno"}`,
- Contains: []string{`"uid":"li"`, `"text":"uno two"`, `"has_children":true`},
+ "Inserted after a block on another branch": {
+ DB: stubContentDB(nil),
+ Args: `{` + targetArgs(_stubBranchID) + `,"position":"after","reference_block_uid":"a","content":"x
"}`,
+ Ops: []string{`"position":"after","reference_uid":"a"`},
},
}
@@ -833,159 +375,128 @@ func Test_updateBlockText_Execute(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- runEdit(t, updateBlockText{}, NameUpdateBlockText, c)
+ runEdit(t, insertBlocks{}, NameInsertBlocks, c)
})
}
}
-func Test_updateBlockAttrsArgs_Validate(t *testing.T) {
+func Test_replaceBlocksArgs_Validate(t *testing.T) {
t.Parallel()
- assertValidate(t, updateBlockAttrsArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "b", Attrs: map[string]any{"level": 2}}, map[string]Args{
- "document_id": updateBlockAttrsArgs{BlockUID: "b", Attrs: map[string]any{"level": 2}},
- "block_uid": updateBlockAttrsArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, Attrs: map[string]any{"level": 2}},
- "attrs": updateBlockAttrsArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "b"},
+ assertValidate(t, replaceBlocksArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "a", Content: "x
"}, map[string]Args{
+ "document_id": replaceBlocksArgs{BlockUID: "a", Content: "x
"},
+ "block_uid": replaceBlocksArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, Content: "x
"},
+ "content": replaceBlocksArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "a"},
})
}
-func Test_updateBlockAttrs_Info(t *testing.T) {
+func Test_replaceBlocks_Info(t *testing.T) {
t.Parallel()
- info := updateBlockAttrs{}.Info()
+ info := replaceBlocks{}.Info()
- assert.Equal(t, NameUpdateBlockAttrs, info.Name)
- assert.Equal(t, []string{"document_id", "branch_id", "block_uid", "attrs"}, info.Required)
+ assert.Equal(t, Traits{Write: true, Overwrites: true}, info.Traits)
+
+ assert.Equal(t, NameReplaceBlocks, info.Name)
+ assert.Equal(t, []string{"document_id", "branch_id", "block_uid", "content"}, info.Required)
}
-func Test_updateBlockAttrs_Traits(t *testing.T) {
+func Test_replaceBlocks_Summary(t *testing.T) {
t.Parallel()
- assert.Equal(t, Traits{Write: true}, updateBlockAttrs{}.Traits())
-}
+ d := testDeps(stubContentDB(nil), nil, nil)
-func Test_updateBlockAttrs_Title(t *testing.T) {
- t.Parallel()
-
- got, err := updateBlockAttrs{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameUpdateBlockAttrs, requiredArgs(t, NameUpdateBlockAttrs)))
+ got, err := replaceBlocks{}.Summary(testInput(d, NameReplaceBlocks,
+ `{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a","content":"x
y
"}`))
require.NoError(t, err)
- assert.Equal(t, "Updating Runbook", got)
+ assert.Equal(t, "Replace a block in Runbook with 2 blocks", got.Summary)
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = updateBlockAttrs{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateBlockAttrs, requiredArgs(t, NameUpdateBlockAttrs)))
+ // content that does not build is refused before anything is shown.
+ _, err = replaceBlocks{}.Summary(testInput(d, NameReplaceBlocks,
+ `{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a","content":"x
"}`))
require.Error(t, err)
- // unreadable arguments are refused before anything is looked up.
- _, err = updateBlockAttrs{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameUpdateBlockAttrs, `{`))
+ // the markup is built against the replaced block only, as the write
+ // builds it, so an id from elsewhere in the document is refused here.
+ _, err = replaceBlocks{}.Summary(testInput(d, NameReplaceBlocks,
+ `{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a","content":"x
"}`))
+ require.Error(t, err)
+
+ _, err = replaceBlocks{}.Summary(testInput(d, NameReplaceBlocks,
+ `{`+targetArgs(_stubMainBranchID)+`,"block_uid":"zzz","content":"x
"}`))
+ testutil.AssertEqualError(t, fmt.Errorf("block %s: %w", "zzz", errUnknownBlock), err)
+
+ _, err = replaceBlocks{}.Summary(testInput(testDeps(failingDocumentDB(), nil, nil), NameReplaceBlocks, requiredArgs(t, NameReplaceBlocks)))
+ require.Error(t, err)
+
+ _, err = replaceBlocks{}.Summary(testInput(d, NameReplaceBlocks, `{`))
require.Error(t, err)
}
-func Test_updateBlockAttrs_Summary(t *testing.T) {
+func Test_replaceBlocks_Execute(t *testing.T) {
t.Parallel()
- d := testDeps(stubDocumentDB(), nil, nil)
-
- // keys are sorted, because the card must read the same every time
- // the same write is proposed.
- got, err := updateBlockAttrs{}.Summary(testInput(d, NameUpdateBlockAttrs,
- `{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a","attrs":{"level":2,"icon":"lucide:warning"}}`))
- require.NoError(t, err)
- assert.Equal(t, "Update block icon, level in Runbook", got.Summary)
-
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = updateBlockAttrs{}.Summary(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateBlockAttrs, requiredArgs(t, NameUpdateBlockAttrs)))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = updateBlockAttrs{}.Summary(testInput(testDeps(stubDocumentDB(), nil, nil), NameUpdateBlockAttrs, `{`))
- require.Error(t, err)
-}
-
-func Test_updateBlockAttrs_Execute(t *testing.T) {
- t.Parallel()
+ args := func(uid, content string) string {
+ return `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + uid + `","content":` + content + `}`
+ }
cc := map[string]editCase{
"Malformed arguments": {DB: stubContentDB(nil), Args: `{`, Err: assert.AnError},
- "Block uid is required": {
- DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"attrs":{"level":2}}`,
- Err: assert.AnError,
- },
- "Attrs must not be empty": {
- DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"h","attrs":{}}`,
- Err: assert.AnError,
- },
"Error returned by db.FetchDocumentByBranchID": {
DB: stubContentDB(assert.AnError),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"h","attrs":{"level":2}}`,
+ Args: args("a", `"x
"`),
Err: assert.AnError,
},
- "Block uid the document does not hold": {
+ "Block the document does not hold": {
DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"zzz","attrs":{"level":2}}`,
+ Args: args("zzz", `"x
"`),
Err: assert.AnError,
},
- "Attrs applied": {
- DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"h","attrs":{"level":2}}`,
- Contains: []string{`"uid":"h"`, `"kind":"heading"`, `"level":2`},
+ "Malformed content": {
+ DB: stubContentDB(nil),
+ Args: args("a", `"x"`),
+ Err: assert.AnError,
},
- // the payload names attributes, not a block type, so a metric's
- // data source arrives on its own rather than inside a block.
- "A data source the organisation owns": {
- DB: stubDataSource(stubSimulationDB()),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + _stubMetricUID + `","attrs":{"dataSourceId":"` +
- _testDataSourceID.String() + `"}}`,
+ "Content holding no block": {
+ DB: stubContentDB(nil),
+ Args: args("a", `" "`),
+ Err: assert.AnError,
},
- "A data source it does not": {
- DB: stubDataSource(stubSimulationDB()),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + _stubMetricUID + `","attrs":{"dataSourceId":"` +
- _unknownDataSourceID + `"}}`,
- Err: assert.AnError,
+ // the callout's paragraph stays where it is, so keeping its id
+ // here would put it in the document twice.
+ "Id of a block outside the one replaced": {
+ DB: stubContentDB(nil),
+ Args: args("a", `"
x
"`),
+ Err: assert.AnError,
},
- // an empty data source is the editor's "unset", not a reference
- // to check, and no other attribute names one at all.
- "An empty data source is not looked up": {
+ "Data source the organisation does not own": {
DB: stubDataSource(stubSimulationDB()),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + _stubMetricUID + `","attrs":{"dataSourceId":""}}`,
+ Args: args("g", metricContent(_unknownDataSourceID)),
+ Err: assert.AnError,
},
- "A simulation flag the caller sent is dropped": {
- DB: stubSimulationDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + _stubMetricUID +
- `","attrs":{"title":"CPU","simulationActive":true}}`,
- Op: `{"kind":"update_attrs","block_uid":"m","attrs":{"title":"CPU"}}`,
+ "Text rewritten in place": {
+ DB: stubContentDB(nil),
+ Args: args("a", `"new words
"`),
+ Contains: []string{`new words
`},
+ Ops: []string{`"kind":"replace","block_uid":"a","block":{"type":"paragraph"`},
},
- "Another preset switches the simulation on": {
- DB: stubSimulationDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + _stubMetricUID +
- `","attrs":{"simulationPreset":"error_rate"}}`,
- Op: `{"kind":"update_attrs","block_uid":"m","attrs":{"simulationPreset":"error_rate","simulationActive":true}}`,
- },
- "A null preset switches the simulation off": {
- DB: stubSimulationDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"` + _stubMetricUID +
- `","attrs":{"simulationPreset":null}}`,
- Op: `{"kind":"update_attrs","block_uid":"m","attrs":{"simulationPreset":null,"simulationActive":false}}`,
- },
- "Titled code title lands on its title row": {
- DB: stubNestedDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"tc","attrs":{"title":"POST /y"}}`,
- Contains: []string{`"uid":"tc"`, `"text":"POST /y old"`},
- Op: `{"kind":"update_attrs","block_uid":"tc","attrs":{"title":"POST /y"}}`,
- },
- "Titled code language alone is accepted": {
- DB: stubNestedDB(),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"tc","attrs":{"language":"python"}}`,
- Op: `{"kind":"update_attrs","block_uid":"tc","attrs":{"language":"python"}}`,
- },
- // only metric blocks have the flag, so any other block is sent
- // unchanged.
- "A block that is not a metric is sent as given": {
+ "One block becomes several": {
DB: stubContentDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"h","attrs":{"level":2}}`,
- Op: `{"kind":"update_attrs","block_uid":"h","attrs":{"level":2}}`,
+ Args: args("a", `"one
two
"`),
+ Ops: []string{`"kind":"replace","block_uid":"a"`, `"kind":"insert","position":"after","reference_uid":"a"`},
+ },
+ "Nested block kept by its id": {
+ DB: stubContentDB(nil),
+ Args: args("c", `"inside
more
"`),
+ Contains: []string{`inside
`},
+ Ops: []string{`"kind":"replace","block_uid":"c"`},
+ },
+ // the metric's data arrived, and re-sending its preset does not
+ // switch the simulation back on.
+ "Metric keeps its simulation flag": {
+ DB: stubSimulationDB(),
+ Args: args(_stubMetricUID, `"{\"simulationPreset\":\"cpu_usage\",\"width\":\"wide\"} "`),
+ Ops: []string{`"simulationActive":false`},
},
}
@@ -993,11 +504,48 @@ func Test_updateBlockAttrs_Execute(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- runEdit(t, updateBlockAttrs{}, NameUpdateBlockAttrs, c)
+ runEdit(t, replaceBlocks{}, NameReplaceBlocks, c)
})
}
}
+func Test_insertBlocksArgs_insertOps(t *testing.T) {
+ t.Parallel()
+
+ blocks := []document.Block{
+ {Type: document.BlockNodeParagraph, Attrs: document.Attributes{document.AttrUID: "x"}},
+ {Type: document.BlockNodeParagraph, Attrs: document.Attributes{document.AttrUID: "y"}},
+ }
+
+ kinds := func(ops []edit.Operation) []string {
+ out := make([]string, 0, len(ops))
+
+ for _, op := range ops {
+ out = append(out, op.Kind+" "+op.Position+" "+op.ReferenceUID)
+ }
+
+ return out
+ }
+
+ ops := func(pos position, ref string) []edit.Operation {
+ return insertBlocksArgs{Position: pos, ReferenceBlockUID: ref}.insertOps(blocks)
+ }
+
+ assert.Equal(t, []string{"insert before r", "insert after x"}, kinds(ops(positionBefore, "r")))
+ assert.Equal(t, []string{"insert after r", "insert after x"}, kinds(ops(positionAfter, "r")))
+ assert.Equal(t, []string{"prepend ", "insert after x"}, kinds(ops(positionStart, "")))
+ assert.Equal(t, []string{"append ", "insert after x"}, kinds(ops(positionEnd, "")))
+}
+
+func Test_insertAfterPrevious(t *testing.T) {
+ t.Parallel()
+
+ first := edit.Delete("x")
+
+ assert.Len(t, insertAfterPrevious(first, []document.Block{{Type: document.BlockNodeParagraph}}), 1)
+ assert.Len(t, insertAfterPrevious(first, make([]document.Block, 3)), 3)
+}
+
func Test_deleteBlockArgs_Validate(t *testing.T) {
t.Parallel()
@@ -1012,35 +560,14 @@ func Test_deleteBlock_Info(t *testing.T) {
info := deleteBlock{}.Info()
+ // a delete stays outside any "approve all" answer.
+ assert.Equal(t, Traits{Write: true, Destructive: true}, info.Traits)
+
assert.Equal(t, NameDeleteBlock, info.Name)
assert.Contains(t, info.Description, "cannot be restored")
assert.Equal(t, []string{"document_id", "branch_id", "block_uid"}, info.Required)
}
-func Test_deleteBlock_Traits(t *testing.T) {
- t.Parallel()
-
- // a delete stays outside any "approve all" answer.
- assert.Equal(t, Traits{Write: true, Destructive: true}, deleteBlock{}.Traits())
-}
-
-func Test_deleteBlock_Title(t *testing.T) {
- t.Parallel()
-
- got, err := deleteBlock{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameDeleteBlock, requiredArgs(t, NameDeleteBlock)))
- require.NoError(t, err)
- assert.Equal(t, "Updating Runbook", got)
-
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = deleteBlock{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameDeleteBlock, requiredArgs(t, NameDeleteBlock)))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = deleteBlock{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameDeleteBlock, `{`))
- require.Error(t, err)
-}
-
func Test_deleteBlock_Summary(t *testing.T) {
t.Parallel()
@@ -1121,34 +648,13 @@ func Test_moveBlock_Info(t *testing.T) {
info := moveBlock{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameMoveBlock, info.Name)
- assert.Contains(t, info.Description, "keeps its uid")
+ assert.Contains(t, info.Description, "keeps its id")
assert.Equal(t, []string{"document_id", "branch_id", "block_uid", "position", "reference_block_uid"}, info.Required)
}
-func Test_moveBlock_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, moveBlock{}.Traits())
-}
-
-func Test_moveBlock_Title(t *testing.T) {
- t.Parallel()
-
- got, err := moveBlock{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameMoveBlock, requiredArgs(t, NameMoveBlock)))
- require.NoError(t, err)
- assert.Equal(t, "Updating Runbook", got)
-
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = moveBlock{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameMoveBlock, requiredArgs(t, NameMoveBlock)))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = moveBlock{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameMoveBlock, `{`))
- require.Error(t, err)
-}
-
func Test_moveBlock_Summary(t *testing.T) {
t.Parallel()
@@ -1201,8 +707,8 @@ func Test_moveBlock_Execute(t *testing.T) {
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"zzz","position":"after","reference_block_uid":"b"}`,
Err: assert.AnError,
},
- "Moved before": {DB: stubContentDB(nil), Args: base + `,"position":"before"}`, Contains: []string{`"uid":"a"`, `"kind":"paragraph"`}},
- "Moved after": {DB: stubContentDB(nil), Args: base + `,"position":"after"}`, Contains: []string{`"uid":"a"`, `"kind":"paragraph"`}},
+ "Moved before": {DB: stubContentDB(nil), Args: base + `,"position":"before"}`, Contains: []string{`hello
`}},
+ "Moved after": {DB: stubContentDB(nil), Args: base + `,"position":"after"}`, Contains: []string{`hello
`}},
}
for cn, c := range cc {
@@ -1213,345 +719,3 @@ func Test_moveBlock_Execute(t *testing.T) {
})
}
}
-
-func Test_findReadable(t *testing.T) {
- t.Parallel()
-
- db := stubNestedDB()
- doc, err := db.FetchDocumentByBranchIDFunc(context.Background(), _stubMainBranchID, "org")
- require.NoError(t, err)
-
- cc := map[string]struct {
- UID string
- Expected string
- Found bool
- }{
- "Block named by its uid": {UID: "c", Expected: "c", Found: true},
- "Nested block named by its uid": {UID: "np", Expected: "np", Found: true},
- "List entry named by its uid": {UID: "li", Expected: "li", Found: true},
- "Part of a block returns its block": {UID: "tt", Expected: "tc", Found: true},
- "Part of a macro returns the macro": {UID: "rs", Expected: "sd", Found: true},
- "Unknown uid": {UID: "zzz"},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- got, ok := findReadable(doc.Content.Content, c.UID)
- assert.Equal(t, c.Found, ok)
-
- uid, _ := got.UID()
- assert.Equal(t, c.Expected, uid)
- })
- }
-}
-
-func Test_withText(t *testing.T) {
- t.Parallel()
-
- content := []document.Block{{Type: document.BlockNodeText, Text: "new"}}
-
- paragraph := func(uid, text string) document.Block {
- return document.Block{
- Type: document.BlockNodeParagraph,
- Attrs: document.Attributes{document.AttrUID: uid},
- Content: []document.Block{{Type: document.BlockNodeText, Text: text}},
- }
- }
-
- cc := map[string]struct {
- Block document.Block
- Expected string
- }{
- "Text leaf takes the text itself": {
- Block: paragraph("p", "old"),
- Expected: "new",
- },
- "Titled code takes it in its code": {
- Block: document.Block{
- Type: document.BlockNodeTitledCodeBlock,
- Content: []document.Block{
- {Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: document.BlockNodeText, Text: "GET /x"}}},
- {Type: document.BlockNodeCodeBlock, Content: []document.Block{{Type: document.BlockNodeText, Text: "old"}}},
- },
- },
- Expected: "GET /x new",
- },
- "List entry takes it in its paragraph and keeps the rest": {
- Block: document.Block{
- Type: document.BlockNodeListItem,
- Content: []document.Block{
- paragraph("p", "old"),
- {Type: document.BlockNodeBulletList, Content: []document.Block{paragraph("q", "kept")}},
- },
- },
- Expected: "new kept",
- },
- "Wrapper without a paragraph is left as it is": {
- Block: document.Block{
- Type: document.BlockNodeCalloutBlock,
- Content: []document.Block{{Type: document.BlockNodeBulletList, Content: []document.Block{paragraph("q", "kept")}}},
- },
- Expected: "kept",
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- before := c.Block.Flatten()
-
- assert.Equal(t, c.Expected, withText(c.Block, content).Flatten())
-
- // the fetched block is left as it was.
- assert.Equal(t, before, c.Block.Flatten())
- })
- }
-}
-
-func Test_withAttrs(t *testing.T) {
- t.Parallel()
-
- titled := document.Block{
- Type: document.BlockNodeTitledCodeBlock,
- Attrs: document.Attributes{document.AttrUID: "tc"},
- Content: []document.Block{
- {Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: document.BlockNodeText, Text: "GET /x"}}},
- {Type: document.BlockNodeCodeBlock, Attrs: document.Attributes{document.AttrLanguage: "go"}},
- {Type: "unknownPart"},
- },
- }
-
- cc := map[string]struct {
- Block document.Block
- Attrs map[string]any
- Expected document.Block
- }{
- "Attrs are laid over the block's own": {
- Block: document.Block{Type: document.BlockNodeHeading, Attrs: document.Attributes{document.AttrUID: "h", document.AttrLevel: 1}},
- Attrs: map[string]any{document.AttrLevel: 2},
- Expected: document.Block{
- Type: document.BlockNodeHeading,
- Attrs: document.Attributes{document.AttrUID: "h", document.AttrLevel: 2},
- },
- },
- "Block without attrs gets them": {
- Block: document.Block{Type: document.BlockNodeCalloutBlock},
- Attrs: map[string]any{document.AttrIcon: "lucide:info"},
- Expected: document.Block{Type: document.BlockNodeCalloutBlock, Attrs: document.Attributes{document.AttrIcon: "lucide:info"}},
- },
- "Titled code takes its title and language on its children": {
- Block: titled,
- Attrs: map[string]any{document.AttrTitle: "POST /y", document.AttrLanguage: "python"},
- Expected: document.Block{
- Type: document.BlockNodeTitledCodeBlock,
- Attrs: document.Attributes{document.AttrUID: "tc"},
- Content: []document.Block{
- {Type: document.BlockNodeCodeBlockTitle, Content: []document.Block{{Type: document.BlockNodeText, Text: "POST /y"}}},
- {Type: document.BlockNodeCodeBlock, Attrs: document.Attributes{document.AttrLanguage: "python"}},
- {Type: "unknownPart"},
- },
- },
- },
- "Titled code keeps what the update does not name": {
- Block: titled,
- Attrs: map[string]any{},
- Expected: titled,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Expected, withAttrs(c.Block, c.Attrs))
- })
- }
-
- // the fetched block is left as it was.
- assert.Equal(t, "go", titled.Content[1].Attrs[document.AttrLanguage])
-}
-
-func Test_sanitizeBlock(t *testing.T) {
- t.Parallel()
-
- metric := func(attrs document.Attributes) block.Block {
- return block.Block{Type: block.BlockMetric, UID: _stubMetricUID, Attrs: attrs}
- }
-
- cc := map[string]struct {
- Block block.Block
- Stored document.Block
- Result document.Attributes
- Err error
- }{
- "Error returned by inp.CheckDataSources": {
- Block: metric(document.Attributes{document.AttrDataSourceID: _unknownDataSourceID}),
- Err: assert.AnError,
- },
- "Error returned by block.Expand": {
- Block: block.Block{Type: "nope"},
- Err: assert.AnError,
- },
- "A new metric naming a preset simulates": {
- Block: metric(document.Attributes{document.AttrSimulationPreset: "cpu_usage"}),
- Result: document.Attributes{
- document.AttrUID: _stubMetricUID,
- document.AttrSimulationPreset: "cpu_usage",
- document.AttrSimulationActive: true,
- },
- },
- "A stored metric keeps its simulation flag": {
- Block: metric(document.Attributes{document.AttrSimulationPreset: "cpu_usage"}),
- Stored: document.Block{
- Type: document.BlockNodeMetricBlock,
- Attrs: document.Attributes{
- document.AttrUID: _stubMetricUID,
- document.AttrSimulationPreset: "cpu_usage",
- document.AttrSimulationActive: false,
- },
- },
- Result: document.Attributes{
- document.AttrUID: _stubMetricUID,
- document.AttrSimulationPreset: "cpu_usage",
- document.AttrSimulationActive: false,
- },
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- inp := testInput(testDeps(stubMetricDB(), nil, nil), NameReplaceBlock, `{}`)
-
- res, err := sanitizeBlock(inp, c.Block, c.Stored)
- testutil.AssertEqualError(t, c.Err, err)
-
- if err != nil {
- return
- }
-
- assert.Equal(t, document.BlockNodeMetricBlock, res.Type)
- assert.Equal(t, c.Result, res.Attrs)
- })
- }
-}
-
-func Test_sanitizeBlockAttrs(t *testing.T) {
- t.Parallel()
-
- stored := document.Block{
- Type: document.BlockNodeMetricBlock,
- Attrs: document.Attributes{
- document.AttrUID: _stubMetricUID,
- document.AttrSimulationPreset: "cpu_usage",
- document.AttrSimulationActive: false,
- },
- }
-
- cc := map[string]struct {
- Attrs map[string]any
- Stored document.Block
- Result map[string]any
- Err error
- }{
- "Error returned by inp.CheckDataSources": {
- Attrs: map[string]any{document.AttrDataSourceID: _unknownDataSourceID},
- Stored: stored,
- Err: assert.AnError,
- },
- "A block that is not a metric is left as given": {
- Attrs: map[string]any{
- document.AttrDataSourceID: _unknownDataSourceID,
- document.AttrSimulationActive: true,
- },
- Stored: document.Block{Type: document.BlockNodeParagraph},
- Result: map[string]any{
- document.AttrDataSourceID: _unknownDataSourceID,
- document.AttrSimulationActive: true,
- },
- },
- "An empty data source is not looked up": {
- Attrs: map[string]any{document.AttrDataSourceID: ""},
- Stored: stored,
- Result: map[string]any{document.AttrDataSourceID: ""},
- },
- "A metric has its simulation flag derived": {
- Attrs: map[string]any{
- document.AttrDataSourceID: _testDataSourceID.String(),
- document.AttrSimulationPreset: "error_rate",
- document.AttrSimulationActive: false,
- },
- Stored: stored,
- Result: map[string]any{
- document.AttrDataSourceID: _testDataSourceID.String(),
- document.AttrSimulationPreset: "error_rate",
- document.AttrSimulationActive: true,
- },
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- inp := testInput(testDeps(stubMetricDB(), nil, nil), NameUpdateBlockAttrs, `{}`)
-
- err := sanitizeBlockAttrs(inp, c.Attrs, c.Stored)
- testutil.AssertEqualError(t, c.Err, err)
-
- if err != nil {
- return
- }
-
- assert.Equal(t, c.Result, c.Attrs)
- })
- }
-}
-
-func Test_docTarget_validate(t *testing.T) {
- t.Parallel()
-
- // error: each half is required, the document first.
- err := docTarget{BranchID: _stubMainBranchID}.validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "document_id is required")
-
- err = docTarget{DocumentID: _testDocID}.validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "branch_id is required")
-
- // success
- require.NoError(t, docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}.validate())
-}
-
-func Test_position_UnmarshalText(t *testing.T) {
- t.Parallel()
-
- for _, want := range []position{positionBefore, positionAfter, positionStart, positionEnd} {
- var got position
-
- require.NoError(t, got.UnmarshalText([]byte(want)))
- assert.Equal(t, want, got)
- }
-
- // anything else is refused by name, so the decoder can report the
- // argument rather than the model guessing what went wrong.
- var got position
-
- err := got.UnmarshalText([]byte("sideways"))
- require.Error(t, err)
- assert.Contains(t, err.Error(), "position must be one of")
-}
-
-func Test_position_relative(t *testing.T) {
- t.Parallel()
-
- assert.True(t, positionBefore.relative())
- assert.True(t, positionAfter.relative())
- assert.False(t, positionStart.relative())
- assert.False(t, positionEnd.relative())
-}
diff --git a/server/core/internal/assistant/tools/confirm.go b/server/core/internal/assistant/tools/confirm.go
index b11924e6..050c2d13 100644
--- a/server/core/internal/assistant/tools/confirm.go
+++ b/server/core/internal/assistant/tools/confirm.go
@@ -11,6 +11,11 @@ import (
"github.com/rs/xid"
)
+// _declinedResult is the tool result reported to the model when the user
+// refuses a write. It is a normal result rather than an error so the
+// model can acknowledge the refusal and carry on.
+const _declinedResult = `{"approved":false,"reason":"user declined"}`
+
// SessionKeyAutoApprove marks a turn in which the user answered a
// confirmation with "approve all". It lives in the agent session so it
// survives the checkpoint written when the turn is interrupted.
@@ -122,7 +127,7 @@ func (c *confirming) InvokableRun(
}
if !hasDecision || !decision.Approved {
- return declinedResult()
+ return _declinedResult, nil
}
return c.einoTool.InvokableRun(ctx, argumentsInJSON, opts...)
@@ -147,19 +152,3 @@ func (c *confirming) interrupt(ctx context.Context, args json.RawMessage) (strin
return "", compose.StatefulInterrupt(ctx, summary, confirmState{})
}
-
-// declinedResult is the tool result reported to the model when the user
-// refuses a write. It is a normal result rather than an error so the
-// model can acknowledge the refusal and carry on.
-func declinedResult() (string, error) {
- res, err := json.Marshal(map[string]any{
- "approved": false,
- "reason": "user declined",
- })
- if err != nil {
- // NOCOV: the payload is a literal of JSON-encodable types.
- return "", err
- }
-
- return string(res), nil
-}
diff --git a/server/core/internal/assistant/tools/confirm_test.go b/server/core/internal/assistant/tools/confirm_test.go
index 4782f768..92c97a0e 100644
--- a/server/core/internal/assistant/tools/confirm_test.go
+++ b/server/core/internal/assistant/tools/confirm_test.go
@@ -2,7 +2,6 @@ package tools
import (
"context"
- "encoding/json"
"testing"
"github.com/cloudwego/eino/adk"
@@ -65,28 +64,28 @@ func Test_confirming_InvokableRun(t *testing.T) {
t.Parallel()
docID := _testDocID
- writeArgs := `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","text":"hi"}`
+ writeArgs := `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","content":"hi
"}`
deleteArgs := `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a"}`
// every write tool must ask before it acts. Resuming an approved
// turn reruns the tool from the top, so a tool that applied its
// edit before interrupting would apply it a second time.
cc := map[string]gateCase{
- "Insert block asks first": {
- Name: NameInsertBlock,
- Args: `{` + targetArgs(_stubMainBranchID) + `,"reference_block_uid":"b","position":"after","block":{"type":"paragraph"}}`,
+ "Insert blocks asks first": {
+ Name: NameInsertBlocks,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,"reference_block_uid":"b","position":"after","content":"x
"}`,
Interrupted: true,
},
- "Delete block asks first": {Name: NameDeleteBlock, Args: deleteArgs, Interrupted: true},
- "Delete document asks first": {Name: NameDeleteDocument, Args: `{"document_id":"` + docID.String() + `"}`, Interrupted: true},
- "Update document asks first": {Name: NameUpdateDocument, Args: `{"document_id":"` + docID.String() + `","name":"n"}`, Interrupted: true},
- "Create document asks first": {Name: NameCreateDocument, Args: `{"name":"n"}`, Interrupted: true},
- "Update block text asks first": {Name: NameUpdateBlockText, Args: writeArgs, Interrupted: true},
+ "Delete block asks first": {Name: NameDeleteBlock, Args: deleteArgs, Interrupted: true},
+ "Delete document asks first": {Name: NameDeleteDocument, Args: `{"document_id":"` + docID.String() + `"}`, Interrupted: true},
+ "Update document asks first": {Name: NameUpdateDocument, Args: `{"document_id":"` + docID.String() + `","name":"n"}`, Interrupted: true},
+ "Create document asks first": {Name: NameCreateDocument, Args: `{"name":"n"}`, Interrupted: true},
+ "Replace blocks asks first": {Name: NameReplaceBlocks, Args: writeArgs, Interrupted: true},
"Approve-all covers a later non-destructive write": {
- Name: NameUpdateBlockText,
+ Name: NameReplaceBlocks,
Args: writeArgs,
AutoApprove: true,
- RespJSON: `{"blocks":[{"uid":"a","kind":"paragraph","text":"hi","depth":0}]}`,
+ RespJSON: `{"content":"hi
\n"}`,
ApplyCalls: 1,
},
"Approve-all never covers a destructive write": {
@@ -96,33 +95,33 @@ func Test_confirming_InvokableRun(t *testing.T) {
Interrupted: true,
},
"Approved confirmation reruns the tool": {
- Name: NameUpdateBlockText,
+ Name: NameReplaceBlocks,
Args: writeArgs,
Decision: &Decision{Approved: true},
- RespJSON: `{"blocks":[{"uid":"a","kind":"paragraph","text":"hi","depth":0}]}`,
+ RespJSON: `{"content":"hi
\n"}`,
ApplyCalls: 1,
},
"Declined confirmation reports the refusal": {
- Name: NameUpdateBlockText,
+ Name: NameReplaceBlocks,
Args: writeArgs,
Decision: &Decision{Approved: false},
RespJSON: `{"approved":false,"reason":"user declined"}`,
},
"Resume of another interrupt point re-interrupts": {
- Name: NameUpdateBlockText,
+ Name: NameReplaceBlocks,
Args: writeArgs,
ResumeOther: true,
Interrupted: true,
},
"Unreadable arguments are refused, never confirmed": {
- Name: NameUpdateBlockText,
+ Name: NameReplaceBlocks,
Args: `{`,
- Rejected: "update_block_text: invalid input",
+ Rejected: "replace_blocks: invalid input",
},
"Missing arguments are refused, never confirmed": {
- Name: NameUpdateBlockText,
+ Name: NameReplaceBlocks,
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"b"}`,
- Rejected: "update_block_text: text is required",
+ Rejected: "replace_blocks: content is required",
},
}
@@ -334,22 +333,6 @@ func (s *memStore) Set(_ context.Context, id string, data []byte) error {
return nil
}
-func Test_declinedResult(t *testing.T) {
- t.Parallel()
-
- res, err := declinedResult()
- require.NoError(t, err)
-
- var out struct {
- Approved bool `json:"approved"`
- Reason string `json:"reason"`
- }
-
- require.NoError(t, json.Unmarshal([]byte(res), &out))
- assert.False(t, out.Approved)
- assert.NotEmpty(t, out.Reason)
-}
-
func Test_autoApproved(t *testing.T) {
t.Parallel()
diff --git a/server/core/internal/assistant/tools/datasource.go b/server/core/internal/assistant/tools/datasource.go
index 9f27201e..4af25282 100644
--- a/server/core/internal/assistant/tools/datasource.go
+++ b/server/core/internal/assistant/tools/datasource.go
@@ -8,6 +8,7 @@ import (
"github.com/oxynote/oxynote/server/core/internal/datasource"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
+ "github.com/prometheus/common/model"
"github.com/rs/xid"
)
@@ -16,6 +17,27 @@ import (
// block's own default preset covers.
const _defaultQueryWindow = time.Hour
+// Caps on a raw query answer. A chart check describes any answer in a
+// few lines; the raw data is for reading values, and past these sizes
+// it costs more than it tells.
+const (
+ // _maxRawSeries caps the series of a raw Prometheus answer.
+ _maxRawSeries = 20
+
+ // _maxRawPoints caps the points of each series in a raw Prometheus
+ // answer, keeping the latest.
+ _maxRawPoints = 100
+
+ // _maxRawRows caps the rows of a raw SQL answer.
+ _maxRawRows = 200
+)
+
+// _maxPreviewSeries caps how many series a chart check describes. A
+// query behind a metric block draws a handful; one answering with more
+// is already the wrong query, and listing them all would cost more than
+// the check saves.
+const _maxPreviewSeries = 10
+
// errUnknownDataSource is what a lookup reports for an id that names
// nothing in the session's organisation. Another organisation's id
// lands here too, which is the point: the tools cannot be used to
@@ -26,6 +48,12 @@ var errUnknownDataSource = errors.New("no data source with that id in this organ
// once either absent end has been defaulted.
var errInvertedTimeRange = errors.New("'from' is after 'to'; the range start must be the earlier timestamp")
+// _timeRangeProps are the from and to arguments every ranged read takes.
+var _timeRangeProps = map[string]any{
+ "from": map[string]any{"type": "string", "description": _fromDescription},
+ "to": map[string]any{"type": "string", "description": _toDescription},
+}
+
// timeRangeArgs is the range every data-source read can be narrowed
// with. Both ends are optional: the tools serve a model that usually
// means "recently" and should not have to compute timestamps to say so.
@@ -83,38 +111,24 @@ type dataSourcesResult struct {
DataSources []dataSourceInfo `json:"data_sources"`
}
-// sqlQueryLabelsResult is what get_sql_query_labels returns.
-type sqlQueryLabelsResult struct {
- // Labels maps each string column the query returned to an example
- // value from its first row.
- Labels map[string]string `json:"labels"`
-}
-
// listDataSources lists the organisation's data sources.
-type listDataSources struct {
- plainSummary
- plainTitle
-}
+type listDataSources struct{}
// Info returns the tool's model-facing description.
func (listDataSources) Info() Info {
return Info{
Name: NameListDataSources,
- Description: "List the organisation's data sources. Returns id, name, type (prometheus, postgresql, mariadb, mysql) and connection status. Start here: every other data-source tool takes an id from this list, and the type decides which of them serves it.",
+ Traits: Traits{DataSource: true},
+ Description: "List the organisation's data sources as {id, name, type, status}. type (prometheus, postgresql, mariadb or mysql) decides the query language and which tools serve it. Start here: the other data source tools take an id from this list.",
Properties: map[string]any{},
}
}
-// Traits reports a plain read.
-func (listDataSources) Traits() Traits {
- return Traits{DataSource: true}
-}
-
// Execute lists the data sources the organisation owns.
-func (listDataSources) Execute(inp Input) (string, error) {
+func (listDataSources) Execute(inp *input) (string, error) {
sources, err := inp.FetchDataSources()
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListDataSources, err)
+ return "", err
}
out := make([]dataSourceInfo, 0, len(sources))
@@ -133,15 +147,14 @@ func (listDataSources) Execute(inp Input) (string, error) {
})
}
-// getPrometheusMetadataArgs is what get_prometheus_metadata is called
-// with.
-type getPrometheusMetadataArgs struct {
- // DataSourceID names the Prometheus data source.
+// dataSourceArgs is what get_data_source_metadata is called with.
+type dataSourceArgs struct {
+ // DataSourceID names the data source.
DataSourceID xid.ID `json:"data_source_id"`
}
// Validate checks the arguments are complete.
-func (a getPrometheusMetadataArgs) Validate() error {
+func (a dataSourceArgs) Validate() error {
if a.DataSourceID.IsNil() {
return errRequired("data_source_id")
}
@@ -149,46 +162,41 @@ func (a getPrometheusMetadataArgs) Validate() error {
return nil
}
-// getPrometheusMetadata lists the metrics a Prometheus data source
-// exposes.
-type getPrometheusMetadata struct {
- plainSummary
-}
+// getDataSourceMetadata describes what a data source holds: the metrics
+// of a Prometheus one, the tables and columns of a SQL one.
+type getDataSourceMetadata struct{}
// Info returns the tool's model-facing description.
-func (getPrometheusMetadata) Info() Info {
+func (getDataSourceMetadata) Info() Info {
return Info{
- Name: NameGetPrometheusMetadata,
- Description: "List the metrics a Prometheus data source exposes, with each metric's type, help text and unit. Use it to find the metric names to write a PromQL query against instead of guessing them.",
- Properties: map[string]any{"data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."}},
+ Name: NameGetDataSourceMetadata,
+ Traits: Traits{DataSource: true},
+ Description: "Describe what a data source holds: for Prometheus, each metric with its type, help and unit; for SQL, {default_schema, tables: {table: [columns]}}. Read it before writing a query, so the names in it are real ones.",
+ Properties: map[string]any{"data_source_id": map[string]any{"type": "string", "description": _dataSourceIDDescription}},
Required: []string{"data_source_id"},
}
}
-// Traits reports a plain read.
-func (getPrometheusMetadata) Traits() Traits {
- return Traits{DataSource: true}
-}
-
// Title announces the data source being read.
-func (getPrometheusMetadata) Title(inp DescribeInput) (string, error) {
- var in getPrometheusMetadataArgs
+func (getDataSourceMetadata) Title(inp DescribeInput) string {
+ var in dataSourceArgs
if err := inp.Decode(&in); err != nil {
- return "", err
+ return ""
}
ds, err := inp.FetchDataSource(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetPrometheusMetadata, err)
+ return ""
}
- return fmt.Sprintf("Reading metric metadata of %q", ds.Name), nil
+ return fmt.Sprintf("Reading what %q holds", ds.Name)
}
-// Execute fetches the data source's metric metadata.
-func (getPrometheusMetadata) Execute(inp Input) (string, error) {
- var in getPrometheusMetadataArgs
+// Execute fetches the metadata of whichever kind of data source the id
+// names.
+func (getDataSourceMetadata) Execute(inp *input) (string, error) {
+ var in dataSourceArgs
if err := inp.Decode(&in); err != nil {
return "", err
@@ -196,36 +204,78 @@ func (getPrometheusMetadata) Execute(inp Input) (string, error) {
runner, err := inp.DataSourceRunner(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetPrometheusMetadata, err)
+ return "", err
}
- prom, err := runner.Prometheus(inp.Context())
+ if runner.Type() == datasource.TypePrometheus {
+ prom, perr := runner.Prometheus(inp.Context())
+ if perr != nil {
+ return "", perr
+ }
+
+ res, merr := prom.Metadata(inp.Context())
+ if merr != nil {
+ return "", merr
+ }
+
+ return result(res)
+ }
+
+ sql, err := runner.SQL(inp.Context())
if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetPrometheusMetadata, err)
+ return "", err
}
- res, err := prom.Metadata(inp.Context())
+ res, err := sql.Metadata(inp.Context())
if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetPrometheusMetadata, err)
+ return "", err
}
- return result(res)
+ out := sqlMetadataResult{
+ DefaultSchema: res.DefaultSchema,
+ Tables: make(map[string][]string, len(res.Tables)),
+ }
+
+ for name, table := range res.Tables {
+ columns := make([]string, 0, len(table.Columns))
+
+ for _, c := range table.Columns {
+ columns = append(columns, c.Name)
+ }
+
+ out.Tables[name] = columns
+ }
+
+ return result(out)
}
-// prometheusLabelNamesArgs is what list_prometheus_label_names is
-// called with.
-type prometheusLabelNamesArgs struct {
+// sqlMetadataResult is what get_data_source_metadata returns for a SQL
+// data source.
+type sqlMetadataResult struct {
+ // DefaultSchema is the schema a table name without one resolves to.
+ DefaultSchema string `json:"default_schema"`
+
+ // Tables maps each schema-qualified table name to its column names.
+ Tables map[string][]string `json:"tables"`
+}
+
+// prometheusLabelsArgs is what list_prometheus_labels is called with.
+type prometheusLabelsArgs struct {
timeRangeArgs
// DataSourceID names the Prometheus data source.
DataSourceID xid.ID `json:"data_source_id"`
- // Matchers narrows the label names to the series they select.
+ // Label, when set, asks for the values of that label instead of
+ // the label names.
+ Label string `json:"label"`
+
+ // Matchers narrows the answer to the series they select.
Matchers []string `json:"matchers"`
}
// Validate checks the arguments are complete.
-func (a prometheusLabelNamesArgs) Validate() error {
+func (a prometheusLabelsArgs) Validate() error {
if a.DataSourceID.IsNil() {
return errRequired("data_source_id")
}
@@ -233,57 +283,51 @@ func (a prometheusLabelNamesArgs) Validate() error {
return nil
}
-// listPrometheusLabelNames lists the label names present in a
-// Prometheus data source.
-type listPrometheusLabelNames struct {
- plainSummary
-}
+// listPrometheusLabels lists the label names of a Prometheus data
+// source, or the values one label takes.
+type listPrometheusLabels struct{}
// Info returns the tool's model-facing description.
-func (listPrometheusLabelNames) Info() Info {
+func (listPrometheusLabels) Info() Info {
return Info{
- Name: NameListPrometheusLabelNames,
- Description: "List the label names present in a Prometheus data source, optionally only those on the series the matchers select. Pair it with list_prometheus_label_values to build a filtered PromQL query.",
+ Name: NameListPrometheusLabels,
+ Traits: Traits{DataSource: true},
+ Description: "List the label names of a Prometheus data source or, with label set, the values that label takes. matchers narrows either to the series they select. Use it to find the labels and values a query should filter on.",
Properties: map[string]any{
- "data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."},
- "matchers": map[string]any{
- "type": "array",
- "description": "Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].",
- "items": map[string]any{
- "type": "string",
- },
- },
- "from": map[string]any{"type": "string", "description": "Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'."},
- "to": map[string]any{"type": "string", "description": "Optional. Range end as an RFC3339 timestamp. Defaults to now."},
+ "data_source_id": map[string]any{"type": "string", "description": _dataSourceIDDescription},
+ "label": map[string]any{"type": "string", "description": "Optional. A label, such as job, whose values to list instead of the names."},
+ "matchers": map[string]any{"type": "array", "description": "Optional. " + _matchersDescription, "items": map[string]any{"type": "string"}},
+ "from": _timeRangeProps["from"],
+ "to": _timeRangeProps["to"],
},
Required: []string{"data_source_id"},
}
}
-// Traits reports a plain read.
-func (listPrometheusLabelNames) Traits() Traits {
- return Traits{DataSource: true}
-}
-
-// Title announces the data source being read.
-func (listPrometheusLabelNames) Title(inp DescribeInput) (string, error) {
- var in prometheusLabelNamesArgs
+// Title announces the data source being read, and the label when one
+// was named.
+func (listPrometheusLabels) Title(inp DescribeInput) string {
+ var in prometheusLabelsArgs
if err := inp.Decode(&in); err != nil {
- return "", err
+ return ""
}
ds, err := inp.FetchDataSource(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelNames, err)
+ return ""
}
- return fmt.Sprintf("Listing label names of %q", ds.Name), nil
+ if in.Label != "" {
+ return fmt.Sprintf("Listing values of label %q in %q", in.Label, ds.Name)
+ }
+
+ return fmt.Sprintf("Listing labels of %q", ds.Name)
}
-// Execute fetches the data source's label names.
-func (listPrometheusLabelNames) Execute(inp Input) (string, error) {
- var in prometheusLabelNamesArgs
+// Execute fetches the label names, or the named label's values.
+func (listPrometheusLabels) Execute(inp *input) (string, error) {
+ var in prometheusLabelsArgs
if err := inp.Decode(&in); err != nil {
return "", err
@@ -291,132 +335,33 @@ func (listPrometheusLabelNames) Execute(inp Input) (string, error) {
tr, err := in.resolve()
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelNames, err)
+ return "", err
}
runner, err := inp.DataSourceRunner(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelNames, err)
+ return "", err
}
prom, err := runner.Prometheus(inp.Context())
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelNames, err)
+ return "", err
+ }
+
+ if in.Label != "" {
+ res, verr := prom.LabelValues(inp.Context(), in.Label, in.Matchers, tr)
+ if verr != nil {
+ return "", verr
+ }
+
+ return result(res)
}
res, err := prom.LabelNames(inp.Context(), in.Matchers, tr)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelNames, err)
- }
-
- return result(res)
-}
-
-// prometheusLabelValuesArgs is what list_prometheus_label_values is
-// called with.
-type prometheusLabelValuesArgs struct {
- timeRangeArgs
-
- // DataSourceID names the Prometheus data source.
- DataSourceID xid.ID `json:"data_source_id"`
-
- // Label is the label whose values are being listed. Required.
- Label string `json:"label"`
-
- // Matchers narrows the values to the series they select.
- Matchers []string `json:"matchers"`
-}
-
-// Validate checks the arguments are complete.
-func (a prometheusLabelValuesArgs) Validate() error {
- if a.DataSourceID.IsNil() {
- return errRequired("data_source_id")
- }
-
- if a.Label == "" {
- return errRequired("label")
- }
-
- return nil
-}
-
-// listPrometheusLabelValues lists the values one label takes in a
-// Prometheus data source.
-type listPrometheusLabelValues struct {
- plainSummary
-}
-
-// Info returns the tool's model-facing description.
-func (listPrometheusLabelValues) Info() Info {
- return Info{
- Name: NameListPrometheusLabelValues,
- Description: "List the values a label takes in a Prometheus data source, optionally only on the series the matchers select. Use it to discover the concrete label values a query should filter on.",
- Properties: map[string]any{
- "data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."},
- "label": map[string]any{"type": "string", "description": "The label whose values to list, e.g. \"job\"."},
- "matchers": map[string]any{
- "type": "array",
- "description": "Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].",
- "items": map[string]any{
- "type": "string",
- },
- },
- "from": map[string]any{"type": "string", "description": "Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'."},
- "to": map[string]any{"type": "string", "description": "Optional. Range end as an RFC3339 timestamp. Defaults to now."},
- },
- Required: []string{"data_source_id", "label"},
- }
-}
-
-// Traits reports a plain read.
-func (listPrometheusLabelValues) Traits() Traits {
- return Traits{DataSource: true}
-}
-
-// Title announces the label and the data source being read.
-func (listPrometheusLabelValues) Title(inp DescribeInput) (string, error) {
- var in prometheusLabelValuesArgs
-
- if err := inp.Decode(&in); err != nil {
return "", err
}
- ds, err := inp.FetchDataSource(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelValues, err)
- }
-
- return fmt.Sprintf("Listing values of label %q in %q", in.Label, ds.Name), nil
-}
-
-// Execute fetches the label's values.
-func (listPrometheusLabelValues) Execute(inp Input) (string, error) {
- var in prometheusLabelValuesArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- tr, err := in.resolve()
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelValues, err)
- }
-
- runner, err := inp.DataSourceRunner(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelValues, err)
- }
-
- prom, err := runner.Prometheus(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelValues, err)
- }
-
- res, err := prom.LabelValues(inp.Context(), in.Label, in.Matchers, tr)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusLabelValues, err)
- }
-
return result(res)
}
@@ -445,54 +390,42 @@ func (a prometheusSeriesArgs) Validate() error {
}
// listPrometheusSeries lists the series matching a set of selectors.
-type listPrometheusSeries struct {
- plainSummary
-}
+type listPrometheusSeries struct{}
// Info returns the tool's model-facing description.
func (listPrometheusSeries) Info() Info {
return Info{
Name: NameListPrometheusSeries,
- Description: "List the series matching a set of selectors in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric actually has before querying it.",
+ Traits: Traits{DataSource: true},
+ Description: "List the series that matchers select in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric has before querying it.",
Properties: map[string]any{
- "data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."},
- "matchers": map[string]any{
- "type": "array",
- "description": "PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].",
- "items": map[string]any{
- "type": "string",
- },
- },
- "from": map[string]any{"type": "string", "description": "Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'."},
- "to": map[string]any{"type": "string", "description": "Optional. Range end as an RFC3339 timestamp. Defaults to now."},
+ "data_source_id": map[string]any{"type": "string", "description": _dataSourceIDDescription},
+ "matchers": map[string]any{"type": "array", "description": _matchersDescription, "items": map[string]any{"type": "string"}},
+ "from": _timeRangeProps["from"],
+ "to": _timeRangeProps["to"],
},
Required: []string{"data_source_id", "matchers"},
}
}
-// Traits reports a plain read.
-func (listPrometheusSeries) Traits() Traits {
- return Traits{DataSource: true}
-}
-
// Title announces the data source being read.
-func (listPrometheusSeries) Title(inp DescribeInput) (string, error) {
+func (listPrometheusSeries) Title(inp DescribeInput) string {
var in prometheusSeriesArgs
if err := inp.Decode(&in); err != nil {
- return "", err
+ return ""
}
ds, err := inp.FetchDataSource(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusSeries, err)
+ return ""
}
- return fmt.Sprintf("Listing series of %q", ds.Name), nil
+ return fmt.Sprintf("Listing series of %q", ds.Name)
}
// Execute fetches the matching series.
-func (listPrometheusSeries) Execute(inp Input) (string, error) {
+func (listPrometheusSeries) Execute(inp *input) (string, error) {
var in prometheusSeriesArgs
if err := inp.Decode(&in); err != nil {
@@ -501,44 +434,44 @@ func (listPrometheusSeries) Execute(inp Input) (string, error) {
tr, err := in.resolve()
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusSeries, err)
+ return "", err
}
runner, err := inp.DataSourceRunner(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusSeries, err)
+ return "", err
}
prom, err := runner.Prometheus(inp.Context())
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusSeries, err)
+ return "", err
}
res, err := prom.Series(inp.Context(), in.Matchers, tr)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameListPrometheusSeries, err)
+ return "", err
}
return result(res)
}
-// queryPrometheusArgs is what query_prometheus is called with.
-type queryPrometheusArgs struct {
+// queryDataSourceArgs is what query_data_source is called with.
+type queryDataSourceArgs struct {
timeRangeArgs
- // DataSourceID names the Prometheus data source.
+ // DataSourceID names the data source.
DataSourceID xid.ID `json:"data_source_id"`
- // Query is the PromQL expression to run. Required.
+ // Query is the PromQL or SQL to run. Required.
Query string `json:"query"`
- // ChartType, when set, asks for the transformed series a metric
- // block of that chart type would render instead of the raw result.
+ // ChartType, when set, asks for what a metric block of that chart
+ // type would draw instead of the raw answer.
ChartType processor.ChartType `json:"chart_type"`
}
// Validate checks the arguments are complete.
-func (a queryPrometheusArgs) Validate() error {
+func (a queryDataSourceArgs) Validate() error {
if a.DataSourceID.IsNil() {
return errRequired("data_source_id")
}
@@ -550,52 +483,52 @@ func (a queryPrometheusArgs) Validate() error {
return nil
}
-// queryPrometheus runs a PromQL range query.
-type queryPrometheus struct {
- plainSummary
-}
+// queryDataSource runs a query against a data source in the language it
+// speaks.
+type queryDataSource struct{}
// Info returns the tool's model-facing description.
-func (queryPrometheus) Info() Info {
+func (queryDataSource) Info() Info {
return Info{
- Name: NameQueryPrometheus,
- Description: "Run a PromQL range query against a Prometheus data source over the given window. Returns the raw result by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block.",
+ Name: NameQueryDataSource,
+ Traits: Traits{DataSource: true},
+ Description: fmt.Sprintf("Run a query over a time range: PromQL for Prometheus, read-only SQL for the others, where $__ macros such as $__timeFilter expand against the range. Without chart_type it returns the raw answer, capped at %d series of %d points or %d rows. With chart_type it returns what a metric block would draw: status, series count, and each series' labels, point count and endpoints. Check a query that way before putting it in a block.", _maxRawSeries, _maxRawPoints, _maxRawRows),
Properties: map[string]any{
- "data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."},
- "query": map[string]any{"type": "string", "description": "The PromQL expression to run."},
- "chart_type": map[string]any{"type": "string", "description": "Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves."},
- "from": map[string]any{"type": "string", "description": "Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'."},
- "to": map[string]any{"type": "string", "description": "Optional. Range end as an RFC3339 timestamp. Defaults to now."},
+ "data_source_id": map[string]any{"type": "string", "description": _dataSourceIDDescription},
+ "query": map[string]any{"type": "string", "description": "The PromQL or SQL to run. For a SQL chart, select a time column aliased time and one or more numeric columns."},
+ "chart_type": map[string]any{
+ "type": "string",
+ "enum": []processor.ChartType{processor.ChartTypeLine, processor.ChartTypeBar, processor.ChartTypeGauge},
+ "description": "Optional. The chart to check the query against; omit it for the values themselves.",
+ },
+ "from": _timeRangeProps["from"],
+ "to": _timeRangeProps["to"],
},
Required: []string{"data_source_id", "query"},
}
}
-// Traits reports a plain read.
-func (queryPrometheus) Traits() Traits {
- return Traits{DataSource: true}
-}
-
// Title announces the data source being queried.
-func (queryPrometheus) Title(inp DescribeInput) (string, error) {
- var in queryPrometheusArgs
+func (queryDataSource) Title(inp DescribeInput) string {
+ var in queryDataSourceArgs
if err := inp.Decode(&in); err != nil {
- return "", err
+ return ""
}
ds, err := inp.FetchDataSource(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameQueryPrometheus, err)
+ return ""
}
- return fmt.Sprintf("Querying %q", ds.Name), nil
+ return fmt.Sprintf("Querying %q", ds.Name)
}
-// Execute runs the query, transforming the result when a chart type
-// was asked for.
-func (queryPrometheus) Execute(inp Input) (string, error) {
- var in queryPrometheusArgs
+// Execute runs the query in whichever language the data source speaks,
+// answering with the raw data or, when a chart type was named, with what
+// the chart would draw.
+func (queryDataSource) Execute(inp *input) (string, error) {
+ var in queryDataSourceArgs
if err := inp.Decode(&in); err != nil {
return "", err
@@ -603,43 +536,147 @@ func (queryPrometheus) Execute(inp Input) (string, error) {
tr, err := in.resolve()
if err != nil {
- return "", fmt.Errorf("%s: %w", NameQueryPrometheus, err)
+ return "", err
}
runner, err := inp.DataSourceRunner(in.DataSourceID)
if err != nil {
- return "", fmt.Errorf("%s: %w", NameQueryPrometheus, err)
+ return "", err
}
- prom, err := runner.Prometheus(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameQueryPrometheus, err)
- }
+ // each dialect answers in its own shape, so this is the one place a
+ // tool has to know which one it is talking to.
+ switch runner.Type() {
+ case datasource.TypePrometheus:
+ prom, err := runner.Prometheus(inp.Context())
+ if err != nil {
+ return "", err
+ }
- res, err := prom.QueryRange(inp.Context(), in.Query, tr)
- if err != nil {
- return "", fmt.Errorf("%s: %w; check the query against get_prometheus_metadata", NameQueryPrometheus, err)
- }
+ res, err := prom.QueryRange(inp.Context(), in.Query, tr)
+ if err != nil {
+ return "", fmt.Errorf("%w; check the query against get_data_source_metadata", err)
+ }
- if in.ChartType == "" {
- return result(res)
- }
+ switch {
+ case res == nil:
+ return result(&processor.QueryResult{Status: processor.QueryStatusNoData})
+ case in.ChartType == "":
+ return result(newRawSeries(res))
+ default:
+ return result(newChartPreview(res.Transform(in.ChartType)))
+ }
+ case datasource.TypePostgreSQL:
+ pg, err := runner.PostgreSQL(inp.Context())
+ if err != nil {
+ return "", err
+ }
- // a query that returned nothing has no result to transform, and the
- // metric block renders it as no-data — which is the answer the
- // model asked for by naming a chart type.
- if res == nil {
- return result(&processor.QueryResult{Status: processor.QueryStatusNoData})
- }
+ res, err := pg.Query(inp.Context(), in.Query, tr)
+ if err != nil {
+ return "", fmt.Errorf("%w; check the query against get_data_source_metadata", err)
+ }
- return result(newChartPreview(res.Transform(in.ChartType)))
+ switch {
+ case res == nil:
+ return result(&processor.QueryResult{Status: processor.QueryStatusNoData})
+ case in.ChartType == "":
+ return result(newRawRows(res.Columns, res.Rows))
+ default:
+ return result(newChartPreview(res.Transform(in.ChartType)))
+ }
+ default:
+ my, err := runner.MySQL(inp.Context())
+ if err != nil {
+ return "", err
+ }
+
+ res, err := my.Query(inp.Context(), in.Query, tr)
+ if err != nil {
+ return "", fmt.Errorf("%w; check the query against get_data_source_metadata", err)
+ }
+
+ switch {
+ case res == nil:
+ return result(&processor.QueryResult{Status: processor.QueryStatusNoData})
+ case in.ChartType == "":
+ return result(newRawRows(res.Columns, res.Rows))
+ default:
+ return result(newChartPreview(res.Transform(in.ChartType)))
+ }
+ }
}
-// _maxPreviewSeries caps how many series a chart check describes. A
-// query behind a metric block draws a handful; one answering with more
-// is already the wrong query, and listing them all would cost more than
-// the check saves.
-const _maxPreviewSeries = 10
+// rawSeries is a raw Prometheus answer, cut down to _maxRawSeries series
+// of the latest _maxRawPoints points each.
+type rawSeries struct {
+ // Warnings are what Prometheus warned about while answering.
+ Warnings []string `json:"warnings,omitempty"`
+
+ // Type is the kind of value the query returned.
+ Type model.ValueType `json:"type"`
+
+ // Result is the answer itself.
+ Result any `json:"result,omitempty"`
+
+ // Truncated says what was left out, when anything was.
+ Truncated string `json:"truncated,omitempty"`
+}
+
+// newRawSeries caps a raw Prometheus answer. Only a range of series can
+// grow past the caps; any other answer is returned as it is.
+func newRawSeries(res *processor.PrometheusQueryResult) rawSeries {
+ out := rawSeries{Warnings: res.Warnings, Type: res.Type, Result: res.Result}
+
+ matrix, ok := res.Result.(model.Matrix)
+ if !ok {
+ return out
+ }
+
+ capped := make(model.Matrix, 0, min(len(matrix), _maxRawSeries))
+ cut := len(matrix) > _maxRawSeries
+
+ for _, s := range matrix[:min(len(matrix), _maxRawSeries)] {
+ if len(s.Values) > _maxRawPoints {
+ s = &model.SampleStream{Metric: s.Metric, Values: s.Values[len(s.Values)-_maxRawPoints:]}
+ cut = true
+ }
+
+ capped = append(capped, s)
+ }
+
+ out.Result = capped
+
+ if cut {
+ out.Truncated = fmt.Sprintf("%d of %d series, each its latest %d points at most; set chart_type to describe them all", len(capped), len(matrix), _maxRawPoints)
+ }
+
+ return out
+}
+
+// rawRows is a raw SQL answer, cut down to its first _maxRawRows rows.
+type rawRows struct {
+ // Columns are the column names.
+ Columns []string `json:"columns"`
+
+ // Rows are the rows, each a value per column.
+ Rows [][]any `json:"rows"`
+
+ // TotalRows is how many rows the query returned, set only when more
+ // than the ones listed.
+ TotalRows int `json:"total_rows,omitempty"`
+}
+
+// newRawRows caps a raw SQL answer.
+func newRawRows(columns []string, rows [][]any) rawRows {
+ out := rawRows{Columns: columns, Rows: rows[:min(len(rows), _maxRawRows)]}
+
+ if len(rows) > _maxRawRows {
+ out.TotalRows = len(rows)
+ }
+
+ return out
+}
// chartPreview is what a query answers with when a chart type was
// named. Naming one asks whether the query renders, not what it
@@ -674,12 +711,6 @@ type chartPreviewSeries struct {
// newChartPreview summarises a transformed result for a chart check.
func newChartPreview(qr *processor.QueryResult) chartPreview {
- if qr == nil {
- // NOCOV: Transform never returns nil; the guard keeps a future
- // caller from panicking on one.
- return chartPreview{Status: processor.QueryStatusNoData}
- }
-
out := chartPreview{
Status: qr.Status,
SeriesCount: len(qr.Data),
@@ -701,334 +732,3 @@ func newChartPreview(qr *processor.QueryResult) chartPreview {
return out
}
-
-// getSQLMetadataArgs is what get_sql_metadata is called with.
-type getSQLMetadataArgs struct {
- // DataSourceID names the SQL data source.
- DataSourceID xid.ID `json:"data_source_id"`
-}
-
-// Validate checks the arguments are complete.
-func (a getSQLMetadataArgs) Validate() error {
- if a.DataSourceID.IsNil() {
- return errRequired("data_source_id")
- }
-
- return nil
-}
-
-// getSQLMetadata lists the tables and columns of a SQL data source.
-type getSQLMetadata struct {
- plainSummary
-}
-
-// Info returns the tool's model-facing description.
-func (getSQLMetadata) Info() Info {
- return Info{
- Name: NameGetSQLMetadata,
- Description: "List the tables and their columns in a PostgreSQL, MariaDB or MySQL data source, plus the default schema. Read it before writing a query so the table and column names are the real ones.",
- Properties: map[string]any{"data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."}},
- Required: []string{"data_source_id"},
- }
-}
-
-// Traits reports a plain read.
-func (getSQLMetadata) Traits() Traits {
- return Traits{DataSource: true}
-}
-
-// Title announces the data source being read.
-func (getSQLMetadata) Title(inp DescribeInput) (string, error) {
- var in getSQLMetadataArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- ds, err := inp.FetchDataSource(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLMetadata, err)
- }
-
- return fmt.Sprintf("Reading tables of %q", ds.Name), nil
-}
-
-// Execute fetches the data source's tables and columns.
-func (getSQLMetadata) Execute(inp Input) (string, error) {
- var in getSQLMetadataArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- runner, err := inp.DataSourceRunner(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLMetadata, err)
- }
-
- sql, err := runner.SQL(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLMetadata, err)
- }
-
- res, err := sql.Metadata(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLMetadata, err)
- }
-
- return result(res)
-}
-
-// sqlQueryLabelsArgs is what get_sql_query_labels is called with.
-type sqlQueryLabelsArgs struct {
- timeRangeArgs
-
- // DataSourceID names the SQL data source.
- DataSourceID xid.ID `json:"data_source_id"`
-
- // Query is the SQL to probe. Required.
- Query string `json:"query"`
-}
-
-// Validate checks the arguments are complete.
-func (a sqlQueryLabelsArgs) Validate() error {
- if a.DataSourceID.IsNil() {
- return errRequired("data_source_id")
- }
-
- if a.Query == "" {
- return errRequired("query")
- }
-
- return nil
-}
-
-// getSQLQueryLabels probes a query for its string columns.
-type getSQLQueryLabels struct {
- plainSummary
-}
-
-// Info returns the tool's model-facing description.
-func (getSQLQueryLabels) Info() Info {
- return Info{
- Name: NameGetSQLQueryLabels,
- Description: "Run a SQL query limited to one row and return its string columns with an example value each, which are the columns a chart would treat as series labels. Use it to check what a query returns before charting it; it is cheaper than query_sql.",
- Properties: map[string]any{
- "data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."},
- "query": map[string]any{"type": "string", "description": "The SQL query to probe. $__ macros are expanded as they are for a metric block."},
- "from": map[string]any{"type": "string", "description": "Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'."},
- "to": map[string]any{"type": "string", "description": "Optional. Range end as an RFC3339 timestamp. Defaults to now."},
- },
- Required: []string{"data_source_id", "query"},
- }
-}
-
-// Traits reports a plain read.
-func (getSQLQueryLabels) Traits() Traits {
- return Traits{DataSource: true}
-}
-
-// Title announces the data source being probed.
-func (getSQLQueryLabels) Title(inp DescribeInput) (string, error) {
- var in sqlQueryLabelsArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- ds, err := inp.FetchDataSource(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLQueryLabels, err)
- }
-
- return fmt.Sprintf("Probing query labels of %q", ds.Name), nil
-}
-
-// Execute probes the query for its string columns.
-func (getSQLQueryLabels) Execute(inp Input) (string, error) {
- var in sqlQueryLabelsArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- tr, err := in.resolve()
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLQueryLabels, err)
- }
-
- runner, err := inp.DataSourceRunner(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLQueryLabels, err)
- }
-
- sql, err := runner.SQL(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLQueryLabels, err)
- }
-
- res, err := sql.QueryLabels(inp.Context(), in.Query, tr)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameGetSQLQueryLabels, err)
- }
-
- return result(sqlQueryLabelsResult{
- Labels: res,
- })
-}
-
-// querySQLArgs is what query_sql is called with.
-type querySQLArgs struct {
- timeRangeArgs
-
- // DataSourceID names the SQL data source.
- DataSourceID xid.ID `json:"data_source_id"`
-
- // Query is the SQL to run. Required.
- Query string `json:"query"`
-
- // ChartType, when set, asks for the transformed series a metric
- // block of that chart type would render instead of the raw rows.
- ChartType processor.ChartType `json:"chart_type"`
-}
-
-// Validate checks the arguments are complete.
-func (a querySQLArgs) Validate() error {
- if a.DataSourceID.IsNil() {
- return errRequired("data_source_id")
- }
-
- if a.Query == "" {
- return errRequired("query")
- }
-
- return nil
-}
-
-// querySQL runs a query against a SQL data source.
-type querySQL struct {
- plainSummary
-}
-
-// Info returns the tool's model-facing description.
-func (querySQL) Info() Info {
- return Info{
- Name: NameQuerySQL,
- Description: "Run a read-only query against a PostgreSQL, MariaDB or MySQL data source. Returns columns and rows by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block. $__ macros ($__timeFilter, $__timeGroupAlias and the rest) are expanded against the window.",
- Properties: map[string]any{
- "data_source_id": map[string]any{"type": "string", "description": "The data source id, from list_data_sources."},
- "query": map[string]any{"type": "string", "description": "The SQL query to run. For a chart, select a time column aliased \"time\" plus one or more numeric columns."},
- "chart_type": map[string]any{"type": "string", "description": "Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves."},
- "from": map[string]any{"type": "string", "description": "Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'."},
- "to": map[string]any{"type": "string", "description": "Optional. Range end as an RFC3339 timestamp. Defaults to now."},
- },
- Required: []string{"data_source_id", "query"},
- }
-}
-
-// Traits reports a plain read.
-func (querySQL) Traits() Traits {
- return Traits{DataSource: true}
-}
-
-// Title announces the data source being queried.
-func (querySQL) Title(inp DescribeInput) (string, error) {
- var in querySQLArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- ds, err := inp.FetchDataSource(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameQuerySQL, err)
- }
-
- return fmt.Sprintf("Querying %q", ds.Name), nil
-}
-
-// Execute runs the query against whichever SQL dialect the data source
-// speaks, transforming the result when a chart type was asked for.
-func (querySQL) Execute(inp Input) (string, error) {
- var in querySQLArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- tr, err := in.resolve()
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameQuerySQL, err)
- }
-
- runner, err := inp.DataSourceRunner(in.DataSourceID)
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameQuerySQL, err)
- }
-
- // the two dialects return different result shapes, so this is the
- // one place a tool has to know which one it is talking to.
- if runner.Type() == datasource.TypePostgreSQL {
- return runPostgreSQLQuery(inp, runner, in.Query, tr, in.ChartType)
- }
-
- return runMySQLQuery(inp, runner, in.Query, tr, in.ChartType)
-}
-
-// runPostgreSQLQuery serves query_sql for a PostgreSQL data source.
-func runPostgreSQLQuery(
- inp Input,
- runner datasource.Runner,
- query string,
- tr processor.TimeRange,
- ct processor.ChartType,
-) (string, error) {
- pg, err := runner.PostgreSQL(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameQuerySQL, err)
- }
-
- res, err := pg.Query(inp.Context(), query, tr)
- if err != nil {
- return "", fmt.Errorf("%s: %w; check the query against get_sql_metadata", NameQuerySQL, err)
- }
-
- if ct == "" {
- return result(res)
- }
-
- if res == nil {
- return result(&processor.QueryResult{Status: processor.QueryStatusNoData})
- }
-
- return result(newChartPreview(res.Transform(ct)))
-}
-
-// runMySQLQuery serves query_sql for a MySQL or MariaDB data source.
-func runMySQLQuery(
- inp Input,
- runner datasource.Runner,
- query string,
- tr processor.TimeRange,
- ct processor.ChartType,
-) (string, error) {
- my, err := runner.MySQL(inp.Context())
- if err != nil {
- return "", fmt.Errorf("%s: %w", NameQuerySQL, err)
- }
-
- res, err := my.Query(inp.Context(), query, tr)
- if err != nil {
- return "", fmt.Errorf("%s: %w; check the query against get_sql_metadata", NameQuerySQL, err)
- }
-
- if ct == "" {
- return result(res)
- }
-
- if res == nil {
- return result(&processor.QueryResult{Status: processor.QueryStatusNoData})
- }
-
- return result(newChartPreview(res.Transform(ct)))
-}
diff --git a/server/core/internal/assistant/tools/datasource_test.go b/server/core/internal/assistant/tools/datasource_test.go
index 00fb2fde..6df026f4 100644
--- a/server/core/internal/assistant/tools/datasource_test.go
+++ b/server/core/internal/assistant/tools/datasource_test.go
@@ -7,11 +7,9 @@ import (
"testing"
"time"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
"github.com/oxynote/oxynote/server/core/internal/datasource"
datasourceMock "github.com/oxynote/oxynote/server/core/internal/datasource/_mock"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
- "github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
@@ -102,7 +100,7 @@ func sqlRunner(client *datasourceMock.SQL) *datasourceMock.Runner {
}
// dialectRunner builds a runner of the given type handing out both
-// dialect clients, so query_sql's own dispatch is what decides which
+// dialect clients, so query_data_source's own dispatch is what decides which
// one is reached.
func dialectRunner(typ datasource.Type, pg *datasourceMock.PostgreSQL, my *datasourceMock.MySQL) *datasourceMock.Runner {
return &datasourceMock.Runner{
@@ -128,7 +126,7 @@ func dialectRunner(typ datasource.Type, pg *datasourceMock.PostgreSQL, my *datas
// against, the arguments the model supplied, and what it should produce.
type dataSourceCase struct {
// Type is the data source's type, which decides what the lookup
- // reports and, for query_sql, which dialect is reached.
+ // reports and, for query_data_source, which dialect is reached.
Type datasource.Type
// Runner is the runner the call reads through. Nil is a don't-care
@@ -196,29 +194,13 @@ func Test_listDataSources_Info(t *testing.T) {
info := listDataSources{}.Info()
+ assert.Equal(t, Traits{DataSource: true}, info.Traits)
+
assert.Equal(t, NameListDataSources, info.Name)
assert.Empty(t, info.Required)
assert.Empty(t, info.Properties)
}
-func Test_listDataSources_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, listDataSources{}.Traits())
-}
-
-func Test_listDataSources_Title(t *testing.T) {
- t.Parallel()
-
- got, err := listDataSources{}.Title(testInput(
- dataSourceDeps(t, datasource.TypePrometheus, nil),
- NameListDataSources,
- "",
- ))
- require.NoError(t, err)
- assert.Empty(t, got)
-}
-
func Test_listDataSources_Execute(t *testing.T) {
t.Parallel()
@@ -263,55 +245,46 @@ func Test_listDataSources_Execute(t *testing.T) {
assert.NotContains(t, got, "credentials")
}
-func Test_getPrometheusMetadataArgs_Validate(t *testing.T) {
+func Test_dataSourceArgs_Validate(t *testing.T) {
t.Parallel()
- assertValidate(t, getPrometheusMetadataArgs{DataSourceID: _testDataSourceID}, map[string]Args{
- "data_source_id": getPrometheusMetadataArgs{},
+ assertValidate(t, dataSourceArgs{DataSourceID: _testDataSourceID}, map[string]Args{
+ "data_source_id": dataSourceArgs{},
})
}
-func Test_getPrometheusMetadata_Info(t *testing.T) {
+func Test_getDataSourceMetadata_Info(t *testing.T) {
t.Parallel()
- info := getPrometheusMetadata{}.Info()
+ info := getDataSourceMetadata{}.Info()
- assert.Equal(t, NameGetPrometheusMetadata, info.Name)
+ assert.Equal(t, Traits{DataSource: true}, info.Traits)
+
+ assert.Equal(t, NameGetDataSourceMetadata, info.Name)
assert.Equal(t, []string{"data_source_id"}, info.Required)
}
-func Test_getPrometheusMetadata_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, getPrometheusMetadata{}.Traits())
-}
-
-func Test_getPrometheusMetadata_Title(t *testing.T) {
+func Test_getDataSourceMetadata_Title(t *testing.T) {
t.Parallel()
d := dataSourceDeps(t, datasource.TypePrometheus, nil)
- other := xid.New().String()
cc := map[string]struct {
Args string
Expected string
- Err error
}{
"A data source that resolves is named": {
Args: `{"data_source_id":"` + _testDataSourceID.String() + `"}`,
- Expected: `Reading metric metadata of "prod"`,
+ Expected: `Reading what "prod" holds`,
},
"One that does not is an error": {
- Args: `{"data_source_id":"` + other + `"}`,
- Err: assert.AnError,
+ Args: `{"data_source_id":"` + xid.New().String() + `"}`,
},
"No id at all": {
Args: `{}`,
- Err: assert.AnError,
},
"Unreadable arguments": {
Args: `{`,
- Err: assert.AnError,
},
}
@@ -319,54 +292,83 @@ func Test_getPrometheusMetadata_Title(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- got, err := getPrometheusMetadata{}.Title(testInput(d, NameGetPrometheusMetadata, c.Args))
-
- testutil.AssertEqualError(t, c.Err, err)
- assert.Equal(t, c.Expected, got)
+ // arguments that name nothing it can describe make no line.
+ assert.Equal(t, c.Expected, getDataSourceMetadata{}.Title(testInput(d, NameGetDataSourceMetadata, c.Args)))
})
}
}
-func Test_getPrometheusMetadata_Execute(t *testing.T) {
+func Test_getDataSourceMetadata_Execute(t *testing.T) {
t.Parallel()
id := _testDataSourceID.String()
- cc := map[string]dataSourceCase{
- "The data source's metric metadata": {
- Type: datasource.TypePrometheus,
- Args: `{"data_source_id":"` + id + `"}`,
- Runner: prometheusRunner(&datasourceMock.Prometheus{
- MetadataFunc: func(context.Context) (*processor.PrometheusMetadataResult, error) {
- return &processor.PrometheusMetadataResult{
- Result: map[string]any{
- "up": "gauge",
- },
- }, nil
+ prom := &datasourceMock.Prometheus{
+ MetadataFunc: func(context.Context) (*processor.PrometheusMetadataResult, error) {
+ return &processor.PrometheusMetadataResult{Result: map[string]any{"up": "gauge"}}, nil
+ },
+ }
+
+ sql := &datasourceMock.SQL{
+ MetadataFunc: func(context.Context) (*processor.SQLMetadataResult, error) {
+ return &processor.SQLMetadataResult{
+ Tables: map[string]processor.SQLTable{
+ "public.orders": {Columns: []processor.SQLColumn{{Name: "id"}, {Name: "total"}}},
},
- }),
+ DefaultSchema: "public",
+ }, nil
+ },
+ }
+
+ cc := map[string]dataSourceCase{
+ "A Prometheus data source's metrics": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(prom),
+ Args: `{"data_source_id":"` + id + `"}`,
Contains: "gauge",
},
+ "A SQL data source's tables, each with its column names": {
+ Type: datasource.TypePostgreSQL,
+ Runner: sqlRunner(sql),
+ Args: `{"data_source_id":"` + id + `"}`,
+ Contains: `{"default_schema":"public","tables":{"public.orders":["id","total"]}}`,
+ },
"Unreadable arguments": {
Type: datasource.TypePrometheus,
Args: `{`,
Err: assert.AnError,
},
"A data source that hands out no Prometheus client": {
- Type: datasource.TypePostgreSQL,
- Args: `{"data_source_id":"` + id + `"}`,
+ Type: datasource.TypePrometheus,
Runner: prometheusRunner(nil),
+ Args: `{"data_source_id":"` + id + `"}`,
Err: assert.AnError,
},
- "A failing read": {
+ "A data source that hands out no SQL client": {
+ Type: datasource.TypePostgreSQL,
+ Runner: sqlRunner(nil),
+ Args: `{"data_source_id":"` + id + `"}`,
+ Err: assert.AnError,
+ },
+ "A failing Prometheus read": {
Type: datasource.TypePrometheus,
- Args: `{"data_source_id":"` + id + `"}`,
Runner: prometheusRunner(&datasourceMock.Prometheus{
MetadataFunc: func(context.Context) (*processor.PrometheusMetadataResult, error) {
return nil, assert.AnError
},
}),
- Err: assert.AnError,
+ Args: `{"data_source_id":"` + id + `"}`,
+ Err: assert.AnError,
+ },
+ "A failing SQL read": {
+ Type: datasource.TypePostgreSQL,
+ Runner: sqlRunner(&datasourceMock.SQL{
+ MetadataFunc: func(context.Context) (*processor.SQLMetadataResult, error) {
+ return nil, assert.AnError
+ },
+ }),
+ Args: `{"data_source_id":"` + id + `"}`,
+ Err: assert.AnError,
},
}
@@ -378,59 +380,72 @@ func Test_getPrometheusMetadata_Execute(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- runDataSourceCase(t, getPrometheusMetadata{}, NameGetPrometheusMetadata, c)
+ runDataSourceCase(t, getDataSourceMetadata{}, NameGetDataSourceMetadata, c)
})
}
}
-func Test_prometheusLabelNamesArgs_Validate(t *testing.T) {
+func Test_prometheusLabelsArgs_Validate(t *testing.T) {
t.Parallel()
- assertValidate(t, prometheusLabelNamesArgs{DataSourceID: _testDataSourceID}, map[string]Args{
- "data_source_id": prometheusLabelNamesArgs{},
+ assertValidate(t, prometheusLabelsArgs{DataSourceID: _testDataSourceID}, map[string]Args{
+ "data_source_id": prometheusLabelsArgs{Label: "job"},
})
}
-func Test_listPrometheusLabelNames_Info(t *testing.T) {
+func Test_listPrometheusLabels_Info(t *testing.T) {
t.Parallel()
- info := listPrometheusLabelNames{}.Info()
+ info := listPrometheusLabels{}.Info()
- assert.Equal(t, NameListPrometheusLabelNames, info.Name)
+ assert.Equal(t, Traits{DataSource: true}, info.Traits)
+
+ assert.Equal(t, NameListPrometheusLabels, info.Name)
assert.Equal(t, []string{"data_source_id"}, info.Required)
+ assert.Contains(t, info.Properties, "label")
assert.Contains(t, info.Properties, "matchers")
assert.Contains(t, info.Properties, "from")
}
-func Test_listPrometheusLabelNames_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, listPrometheusLabelNames{}.Traits())
-}
-
-func Test_listPrometheusLabelNames_Title(t *testing.T) {
+func Test_listPrometheusLabels_Title(t *testing.T) {
t.Parallel()
d := dataSourceDeps(t, datasource.TypePrometheus, nil)
+ id := _testDataSourceID.String()
- got, err := listPrometheusLabelNames{}.Title(testInput(
- d,
- NameListPrometheusLabelNames,
- `{"data_source_id":"`+_testDataSourceID.String()+`"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, `Listing label names of "prod"`, got)
+ cc := map[string]struct {
+ Args string
+ Expected string
+ }{
+ "The label names": {
+ Args: `{"data_source_id":"` + id + `"}`,
+ Expected: `Listing labels of "prod"`,
+ },
+ "One label's values": {
+ Args: `{"data_source_id":"` + id + `","label":"job"}`,
+ Expected: `Listing values of label "job" in "prod"`,
+ },
+ // a data source the organisation does not own is an error, not a
+ // label: the model is told what it named does not exist.
+ "A data source of another organisation": {
+ Args: `{"data_source_id":"` + xid.New().String() + `"}`,
+ },
+ "Unreadable arguments": {
+ Args: `{`,
+ },
+ }
- _, err = listPrometheusLabelNames{}.Title(testInput(d, NameListPrometheusLabelNames, `{`))
- require.Error(t, err)
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err = listPrometheusLabelNames{}.Title(testInput(d, NameListPrometheusLabelNames, `{"data_source_id":"`+xid.New().String()+`"}`))
- require.Error(t, err)
+ // arguments that name nothing it can describe make no line.
+ assert.Equal(t, c.Expected, listPrometheusLabels{}.Title(testInput(d, NameListPrometheusLabels, c.Args)))
+ })
+ }
}
-func Test_listPrometheusLabelNames_Execute(t *testing.T) {
+func Test_listPrometheusLabels_Execute(t *testing.T) {
t.Parallel()
id := _testDataSourceID.String()
@@ -445,6 +460,13 @@ func Test_listPrometheusLabelNames_Execute(t *testing.T) {
return &processor.PrometheusLabelNamesResult{Result: []string{"job"}}, nil
},
+ LabelValuesFunc: func(_ context.Context, label string, _ []string, _ processor.TimeRange) (*processor.PrometheusLabelValuesResult, error) {
+ if label == "boom" {
+ return nil, assert.AnError
+ }
+
+ return &processor.PrometheusLabelValuesResult{Result: []string{"api"}}, nil
+ },
}
cc := map[string]dataSourceCase{
@@ -452,7 +474,13 @@ func Test_listPrometheusLabelNames_Execute(t *testing.T) {
Type: datasource.TypePrometheus,
Runner: prometheusRunner(client),
Args: `{"data_source_id":"` + id + `","matchers":["up"]}`,
- Contains: "job",
+ Contains: `["job"]`,
+ },
+ "The values a label takes": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(client),
+ Args: `{"data_source_id":"` + id + `","label":"job"}`,
+ Contains: `["api"]`,
},
"Unreadable arguments": {
Type: datasource.TypePrometheus,
@@ -478,12 +506,18 @@ func Test_listPrometheusLabelNames_Execute(t *testing.T) {
Args: `{"data_source_id":"` + id + `"}`,
Err: assert.AnError,
},
- "A failing read": {
+ "A failing names read": {
Type: datasource.TypePrometheus,
Runner: prometheusRunner(client),
Args: `{"data_source_id":"` + id + `","matchers":["boom"]}`,
Err: assert.AnError,
},
+ "A failing values read": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(client),
+ Args: `{"data_source_id":"` + id + `","label":"boom"}`,
+ Err: assert.AnError,
+ },
}
maps.Copy(cc, badIDCases(func(id string) string {
@@ -494,146 +528,7 @@ func Test_listPrometheusLabelNames_Execute(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- runDataSourceCase(t, listPrometheusLabelNames{}, NameListPrometheusLabelNames, c)
- })
- }
-}
-
-func Test_prometheusLabelValuesArgs_Validate(t *testing.T) {
- t.Parallel()
-
- assertValidate(t, prometheusLabelValuesArgs{DataSourceID: _testDataSourceID, Label: "job"}, map[string]Args{
- "data_source_id": prometheusLabelValuesArgs{Label: "job"},
- "label": prometheusLabelValuesArgs{DataSourceID: _testDataSourceID},
- })
-}
-
-func Test_listPrometheusLabelValues_Info(t *testing.T) {
- t.Parallel()
-
- info := listPrometheusLabelValues{}.Info()
-
- assert.Equal(t, NameListPrometheusLabelValues, info.Name)
- assert.Equal(t, []string{"data_source_id", "label"}, info.Required)
-}
-
-func Test_listPrometheusLabelValues_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, listPrometheusLabelValues{}.Traits())
-}
-
-func Test_listPrometheusLabelValues_Title(t *testing.T) {
- t.Parallel()
-
- d := dataSourceDeps(t, datasource.TypePrometheus, nil)
- id := _testDataSourceID.String()
-
- cc := map[string]struct {
- Args string
- Expected string
- Err error
- }{
- "The label and the data source": {
- Args: `{"data_source_id":"` + id + `","label":"job"}`,
- Expected: `Listing values of label "job" in "prod"`,
- },
- "No label yet": {
- Args: `{"data_source_id":"` + id + `"}`,
- Err: assert.AnError,
- },
- "Unreadable arguments": {
- Args: `{`,
- Err: assert.AnError,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- got, err := listPrometheusLabelValues{}.Title(testInput(d, NameListPrometheusLabelValues, c.Args))
-
- testutil.AssertEqualError(t, c.Err, err)
- assert.Equal(t, c.Expected, got)
- })
- }
-
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err := listPrometheusLabelValues{}.Title(testInput(d, NameListPrometheusLabelValues, `{"data_source_id":"`+xid.New().String()+`","label":"job"}`))
- require.Error(t, err)
-}
-
-func Test_listPrometheusLabelValues_Execute(t *testing.T) {
- t.Parallel()
-
- id := _testDataSourceID.String()
-
- client := &datasourceMock.Prometheus{
- LabelValuesFunc: func(_ context.Context, label string, _ []string, _ processor.TimeRange) (*processor.PrometheusLabelValuesResult, error) {
- if label == "boom" {
- return nil, assert.AnError
- }
-
- return &processor.PrometheusLabelValuesResult{Result: []string{"api"}}, nil
- },
- }
-
- cc := map[string]dataSourceCase{
- "The values the label takes": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client),
- Args: `{"data_source_id":"` + id + `","label":"job"}`,
- Contains: "api",
- },
- "Unreadable arguments": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client),
- Args: `{`,
- Err: assert.AnError,
- },
- "No label": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client),
- Args: `{"data_source_id":"` + id + `"}`,
- Err: assert.AnError,
- },
- "An unparseable range end": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client),
- Args: `{"data_source_id":"` + id + `","label":"job","to":"soon"}`,
- Err: assert.AnError,
- },
- "An inverted range": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client),
- Args: `{"data_source_id":"` + id + `","label":"job","from":"2026-08-01T12:00:00Z","to":"2026-08-01T10:00:00Z"}`,
- Err: assert.AnError,
- },
- "A data source that hands out no Prometheus client": {
- Type: datasource.TypePostgreSQL,
- Runner: prometheusRunner(nil),
- Args: `{"data_source_id":"` + id + `","label":"job"}`,
- Err: assert.AnError,
- },
- "A failing read": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client),
- Args: `{"data_source_id":"` + id + `","label":"boom"}`,
- Err: assert.AnError,
- },
- }
-
- maps.Copy(cc, badIDCases(func(id string) string {
- return `{"data_source_id":"` + id + `","label":"job"}`
- }))
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- runDataSourceCase(t, listPrometheusLabelValues{}, NameListPrometheusLabelValues, c)
+ runDataSourceCase(t, listPrometheusLabels{}, NameListPrometheusLabels, c)
})
}
}
@@ -652,36 +547,26 @@ func Test_listPrometheusSeries_Info(t *testing.T) {
info := listPrometheusSeries{}.Info()
+ assert.Equal(t, Traits{DataSource: true}, info.Traits)
+
assert.Equal(t, NameListPrometheusSeries, info.Name)
assert.Equal(t, []string{"data_source_id", "matchers"}, info.Required)
}
-func Test_listPrometheusSeries_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, listPrometheusSeries{}.Traits())
-}
-
func Test_listPrometheusSeries_Title(t *testing.T) {
t.Parallel()
d := dataSourceDeps(t, datasource.TypePrometheus, nil)
- got, err := listPrometheusSeries{}.Title(testInput(
+ assert.Equal(t, `Listing series of "prod"`, listPrometheusSeries{}.Title(testInput(
d,
NameListPrometheusSeries,
`{"data_source_id":"`+_testDataSourceID.String()+`","matchers":["up"]}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, `Listing series of "prod"`, got)
+ )))
- _, err = listPrometheusSeries{}.Title(testInput(d, NameListPrometheusSeries, `{`))
- require.Error(t, err)
-
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err = listPrometheusSeries{}.Title(testInput(d, NameListPrometheusSeries, `{"data_source_id":"`+xid.New().String()+`","matchers":["up"]}`))
- require.Error(t, err)
+ // arguments that name nothing it can describe make no line.
+ assert.Empty(t, listPrometheusSeries{}.Title(testInput(d, NameListPrometheusSeries, `{`)))
+ assert.Empty(t, listPrometheusSeries{}.Title(testInput(d, NameListPrometheusSeries, `{"data_source_id":"`+xid.New().String()+`","matchers":["up"]}`)))
}
func Test_listPrometheusSeries_Execute(t *testing.T) {
@@ -769,76 +654,70 @@ func Test_listPrometheusSeries_Execute(t *testing.T) {
}
}
-func Test_queryPrometheusArgs_Validate(t *testing.T) {
+func Test_queryDataSourceArgs_Validate(t *testing.T) {
t.Parallel()
- assertValidate(t, queryPrometheusArgs{DataSourceID: _testDataSourceID, Query: "up"}, map[string]Args{
- "data_source_id": queryPrometheusArgs{Query: "up"},
- "query": queryPrometheusArgs{DataSourceID: _testDataSourceID},
+ assertValidate(t, queryDataSourceArgs{DataSourceID: _testDataSourceID, Query: "up"}, map[string]Args{
+ "data_source_id": queryDataSourceArgs{Query: "up"},
+ "query": queryDataSourceArgs{DataSourceID: _testDataSourceID},
})
}
-func Test_queryPrometheus_Info(t *testing.T) {
+func Test_queryDataSource_Info(t *testing.T) {
t.Parallel()
- info := queryPrometheus{}.Info()
+ info := queryDataSource{}.Info()
- assert.Equal(t, NameQueryPrometheus, info.Name)
+ assert.Equal(t, Traits{DataSource: true}, info.Traits)
+
+ assert.Equal(t, NameQueryDataSource, info.Name)
assert.Equal(t, []string{"data_source_id", "query"}, info.Required)
- assert.Contains(t, info.Properties, "chart_type")
- // the chart types the metric block schema declares are exactly the
- // ones chart_type accepts. The block package cannot import the
- // processor — that would drag the Prometheus client and pgx into a
- // leaf package — so the two lists are only kept equal by this check.
- for _, v := range block.MetricEnums()[document.AttrVisualizationType] {
- assert.True(t, processor.ChartType(v).IsValid(), "%q is in the block schema but not a chart type", v)
- assert.Contains(t, "Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.", v)
- }
+ chartType, ok := info.Properties["chart_type"].(map[string]any)
+ require.True(t, ok)
- for _, ct := range []processor.ChartType{
- processor.ChartTypeLine,
- processor.ChartTypeBar,
- processor.ChartTypeGauge,
- } {
- assert.Contains(t, block.MetricEnums()[document.AttrVisualizationType], string(ct))
- }
+ assert.Equal(t, []processor.ChartType{processor.ChartTypeLine, processor.ChartTypeBar, processor.ChartTypeGauge}, chartType["enum"])
}
-func Test_queryPrometheus_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, queryPrometheus{}.Traits())
-}
-
-func Test_queryPrometheus_Title(t *testing.T) {
+func Test_queryDataSource_Title(t *testing.T) {
t.Parallel()
d := dataSourceDeps(t, datasource.TypePrometheus, nil)
- got, err := queryPrometheus{}.Title(testInput(
- d,
- NameQueryPrometheus,
- `{"data_source_id":"`+_testDataSourceID.String()+`","query":"up"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, `Querying "prod"`, got)
+ cc := map[string]struct {
+ Args string
+ Expected string
+ }{
+ "A data source that resolves is named": {
+ Args: `{"data_source_id":"` + _testDataSourceID.String() + `","query":"up"}`,
+ Expected: `Querying "prod"`,
+ },
+ // a data source the organisation does not own is an error, not a
+ // label: the model is told what it named does not exist.
+ "A data source of another organisation": {
+ Args: `{"data_source_id":"` + xid.New().String() + `","query":"up"}`,
+ },
+ "Unreadable arguments": {
+ Args: `{`,
+ },
+ }
- _, err = queryPrometheus{}.Title(testInput(d, NameQueryPrometheus, `{`))
- require.Error(t, err)
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err = queryPrometheus{}.Title(testInput(d, NameQueryPrometheus, `{"data_source_id":"`+xid.New().String()+`","query":"up"}`))
- require.Error(t, err)
+ // arguments that name nothing it can describe make no line.
+ assert.Equal(t, c.Expected, queryDataSource{}.Title(testInput(d, NameQueryDataSource, c.Args)))
+ })
+ }
}
-func Test_queryPrometheus_Execute(t *testing.T) {
+func Test_queryDataSource_Execute(t *testing.T) {
t.Parallel()
id := _testDataSourceID.String()
- client := func(res *processor.PrometheusQueryResult) *datasourceMock.Prometheus {
+ prom := func(res *processor.PrometheusQueryResult) *datasourceMock.Prometheus {
return &datasourceMock.Prometheus{
QueryRangeFunc: func(_ context.Context, q string, _ processor.TimeRange) (*processor.PrometheusQueryResult, error) {
if q == "boom" {
@@ -850,407 +729,59 @@ func Test_queryPrometheus_Execute(t *testing.T) {
}
}
- cc := map[string]dataSourceCase{
- "A raw query returns the raw result": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(&processor.PrometheusQueryResult{
- Type: model.ValMatrix,
- Warnings: []string{"slow"},
- })),
- Args: `{"data_source_id":"` + id + `","query":"up"}`,
- Contains: `"warnings":["slow"]`,
- },
- "A chart query returns the transformed series": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(&processor.PrometheusQueryResult{
- Type: model.ValMatrix,
- })),
- Args: `{"data_source_id":"` + id + `","query":"up","chart_type":"line_chart"}`,
- Contains: `"status"`,
- },
- // a query that returned nothing has no result to transform, and the
- // metric block renders it as no-data — which is the answer the model
- // asked for by naming a chart type.
- "A chart query with no result at all is no-data": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{"data_source_id":"` + id + `","query":"up","chart_type":"gauge_chart"}`,
- Contains: `"status":"no-data"`,
- },
- "Unreadable arguments": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{`,
- Err: assert.AnError,
- },
- "No query": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{"data_source_id":"` + id + `"}`,
- Err: assert.AnError,
- },
- "An unknown chart type": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{"data_source_id":"` + id + `","query":"up","chart_type":"pie_chart"}`,
- Err: assert.AnError,
- },
- "An unparseable range": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{"data_source_id":"` + id + `","query":"up","from":"now"}`,
- Err: assert.AnError,
- },
- "An inverted range": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{"data_source_id":"` + id + `","query":"up","from":"2026-08-01T12:00:00Z","to":"2026-08-01T10:00:00Z"}`,
- Err: assert.AnError,
- },
- "A data source that hands out no Prometheus client": {
- Type: datasource.TypePostgreSQL,
- Runner: prometheusRunner(nil),
- Args: `{"data_source_id":"` + id + `","query":"up"}`,
- Err: assert.AnError,
- },
- "A failing read": {
- Type: datasource.TypePrometheus,
- Runner: prometheusRunner(client(nil)),
- Args: `{"data_source_id":"` + id + `","query":"boom"}`,
- Err: assert.AnError,
- },
- }
-
- maps.Copy(cc, badIDCases(func(id string) string {
- return `{"data_source_id":"` + id + `","query":"up"}`
- }))
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- runDataSourceCase(t, queryPrometheus{}, NameQueryPrometheus, c)
- })
- }
-}
-
-func Test_getSQLMetadataArgs_Validate(t *testing.T) {
- t.Parallel()
-
- assertValidate(t, getSQLMetadataArgs{DataSourceID: _testDataSourceID}, map[string]Args{
- "data_source_id": getSQLMetadataArgs{},
- })
-}
-
-func Test_getSQLMetadata_Info(t *testing.T) {
- t.Parallel()
-
- info := getSQLMetadata{}.Info()
-
- assert.Equal(t, NameGetSQLMetadata, info.Name)
- assert.Equal(t, []string{"data_source_id"}, info.Required)
-}
-
-func Test_getSQLMetadata_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, getSQLMetadata{}.Traits())
-}
-
-func Test_getSQLMetadata_Title(t *testing.T) {
- t.Parallel()
-
- d := dataSourceDeps(t, datasource.TypePostgreSQL, nil)
-
- got, err := getSQLMetadata{}.Title(testInput(
- d,
- NameGetSQLMetadata,
- `{"data_source_id":"`+_testDataSourceID.String()+`"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, `Reading tables of "prod"`, got)
-
- _, err = getSQLMetadata{}.Title(testInput(d, NameGetSQLMetadata, `{`))
- require.Error(t, err)
-
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err = getSQLMetadata{}.Title(testInput(d, NameGetSQLMetadata, `{"data_source_id":"`+xid.New().String()+`"}`))
- require.Error(t, err)
-}
-
-func Test_getSQLMetadata_Execute(t *testing.T) {
- t.Parallel()
-
- id := _testDataSourceID.String()
-
- client := &datasourceMock.SQL{
- MetadataFunc: func(context.Context) (*processor.SQLMetadataResult, error) {
- return &processor.SQLMetadataResult{
- Tables: map[string]processor.SQLTable{
- "public.orders": {
- Columns: []processor.SQLColumn{
- {Name: "id"},
- },
- },
- },
- DefaultSchema: "public",
- }, nil
- },
- }
-
- cc := map[string]dataSourceCase{
- "The data source's tables and columns": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{"data_source_id":"` + id + `"}`,
- Contains: "public.orders",
- },
- "Unreadable arguments": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{`,
- Err: assert.AnError,
- },
- "A data source that hands out no SQL client": {
- Type: datasource.TypePrometheus,
- Runner: sqlRunner(nil),
- Args: `{"data_source_id":"` + id + `"}`,
- Err: assert.AnError,
- },
- "A failing read": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(&datasourceMock.SQL{
- MetadataFunc: func(context.Context) (*processor.SQLMetadataResult, error) {
- return nil, assert.AnError
- },
- }),
- Args: `{"data_source_id":"` + id + `"}`,
- Err: assert.AnError,
- },
- }
-
- maps.Copy(cc, badIDCases(func(id string) string {
- return `{"data_source_id":"` + id + `"}`
- }))
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- runDataSourceCase(t, getSQLMetadata{}, NameGetSQLMetadata, c)
- })
- }
-}
-
-func Test_sqlQueryLabelsArgs_Validate(t *testing.T) {
- t.Parallel()
-
- assertValidate(t, sqlQueryLabelsArgs{DataSourceID: _testDataSourceID, Query: "select 1"}, map[string]Args{
- "data_source_id": sqlQueryLabelsArgs{Query: "select 1"},
- "query": sqlQueryLabelsArgs{DataSourceID: _testDataSourceID},
- })
-}
-
-func Test_getSQLQueryLabels_Info(t *testing.T) {
- t.Parallel()
-
- info := getSQLQueryLabels{}.Info()
-
- assert.Equal(t, NameGetSQLQueryLabels, info.Name)
- assert.Equal(t, []string{"data_source_id", "query"}, info.Required)
-}
-
-func Test_getSQLQueryLabels_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, getSQLQueryLabels{}.Traits())
-}
-
-func Test_getSQLQueryLabels_Title(t *testing.T) {
- t.Parallel()
-
- d := dataSourceDeps(t, datasource.TypeMySQL, nil)
-
- got, err := getSQLQueryLabels{}.Title(testInput(
- d,
- NameGetSQLQueryLabels,
- `{"data_source_id":"`+_testDataSourceID.String()+`","query":"select 1"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, `Probing query labels of "prod"`, got)
-
- _, err = getSQLQueryLabels{}.Title(testInput(d, NameGetSQLQueryLabels, `{`))
- require.Error(t, err)
-
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err = getSQLQueryLabels{}.Title(testInput(d, NameGetSQLQueryLabels, `{"data_source_id":"`+xid.New().String()+`","query":"select 1"}`))
- require.Error(t, err)
-}
-
-func Test_getSQLQueryLabels_Execute(t *testing.T) {
- t.Parallel()
-
- id := _testDataSourceID.String()
-
- client := &datasourceMock.SQL{
- QueryLabelsFunc: func(_ context.Context, q string, _ processor.TimeRange) (map[string]string, error) {
- if q == "boom" {
- return nil, assert.AnError
- }
-
- return map[string]string{"region": "eu"}, nil
- },
- }
-
- cc := map[string]dataSourceCase{
- "The query's string columns": {
- Type: datasource.TypeMariaDB,
- Runner: sqlRunner(client),
- Args: `{"data_source_id":"` + id + `","query":"select id from orders"}`,
- Contains: `"region":"eu"`,
- },
- "Unreadable arguments": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{`,
- Err: assert.AnError,
- },
- "No query": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{"data_source_id":"` + id + `"}`,
- Err: assert.AnError,
- },
- "An unparseable range": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{"data_source_id":"` + id + `","query":"select 1","to":"later"}`,
- Err: assert.AnError,
- },
- "An inverted range": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{"data_source_id":"` + id + `","query":"select 1","from":"2026-08-01T12:00:00Z","to":"2026-08-01T10:00:00Z"}`,
- Err: assert.AnError,
- },
- "A data source that hands out no SQL client": {
- Type: datasource.TypePrometheus,
- Runner: sqlRunner(nil),
- Args: `{"data_source_id":"` + id + `","query":"select 1"}`,
- Err: assert.AnError,
- },
- "A failing read": {
- Type: datasource.TypePostgreSQL,
- Runner: sqlRunner(client),
- Args: `{"data_source_id":"` + id + `","query":"boom"}`,
- Err: assert.AnError,
- },
- }
-
- maps.Copy(cc, badIDCases(func(id string) string {
- return `{"data_source_id":"` + id + `","query":"select 1"}`
- }))
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- runDataSourceCase(t, getSQLQueryLabels{}, NameGetSQLQueryLabels, c)
- })
- }
-}
-
-func Test_querySQLArgs_Validate(t *testing.T) {
- t.Parallel()
-
- assertValidate(t, querySQLArgs{DataSourceID: _testDataSourceID, Query: "select 1"}, map[string]Args{
- "data_source_id": querySQLArgs{Query: "select 1"},
- "query": querySQLArgs{DataSourceID: _testDataSourceID},
- })
-}
-
-func Test_querySQL_Info(t *testing.T) {
- t.Parallel()
-
- info := querySQL{}.Info()
-
- assert.Equal(t, NameQuerySQL, info.Name)
- assert.Equal(t, []string{"data_source_id", "query"}, info.Required)
- assert.Contains(t, info.Properties, "chart_type")
-}
-
-func Test_querySQL_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{DataSource: true}, querySQL{}.Traits())
-}
-
-func Test_querySQL_Title(t *testing.T) {
- t.Parallel()
-
- d := dataSourceDeps(t, datasource.TypePostgreSQL, nil)
-
- got, err := querySQL{}.Title(testInput(
- d,
- NameQuerySQL,
- `{"data_source_id":"`+_testDataSourceID.String()+`","query":"select 1"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, `Querying "prod"`, got)
-
- _, err = querySQL{}.Title(testInput(d, NameQuerySQL, `{`))
- require.Error(t, err)
-
- // a data source the organisation does not own is an error, not a
- // label: the model is told what it named does not exist.
- _, err = querySQL{}.Title(testInput(d, NameQuerySQL, `{"data_source_id":"`+xid.New().String()+`","query":"select 1"}`))
- require.Error(t, err)
-}
-
-func Test_querySQL_Execute(t *testing.T) {
- t.Parallel()
-
- id := _testDataSourceID.String()
-
rows := [][]any{
{float64(1), float64(2)},
}
pg := &datasourceMock.PostgreSQL{
QueryFunc: func(_ context.Context, q string, _ processor.TimeRange) (*processor.PostgreSQLQueryResult, error) {
- if q == "boom" {
+ switch q {
+ case "boom":
return nil, assert.AnError
- }
-
- // a query the data source answered with nothing at all: the
- // result is absent, not an error.
- if q == "empty" {
+ case "empty":
//nolint:nilnil // an absent result is what the executor reports here
return nil, nil
+ default:
+ return &processor.PostgreSQLQueryResult{Columns: []string{"time", "value"}, Rows: rows}, nil
}
-
- return &processor.PostgreSQLQueryResult{Columns: []string{"time", "value"}, Rows: rows}, nil
},
}
my := &datasourceMock.MySQL{
QueryFunc: func(_ context.Context, q string, _ processor.TimeRange) (*processor.MySQLQueryResult, error) {
- if q == "boom" {
+ switch q {
+ case "boom":
return nil, assert.AnError
- }
-
- if q == "empty" {
+ case "empty":
//nolint:nilnil // an absent result is what the executor reports here
return nil, nil
+ default:
+ return &processor.MySQLQueryResult{Columns: []string{"time", "value"}, Rows: rows}, nil
}
-
- return &processor.MySQLQueryResult{Columns: []string{"time", "value"}, Rows: rows}, nil
},
}
cc := map[string]dataSourceCase{
+ "Prometheus raw": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(prom(&processor.PrometheusQueryResult{Type: model.ValMatrix, Warnings: []string{"slow"}})),
+ Args: `{"data_source_id":"` + id + `","query":"up"}`,
+ Contains: `"warnings":["slow"]`,
+ },
+ "Prometheus charted": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(prom(&processor.PrometheusQueryResult{Type: model.ValMatrix})),
+ Args: `{"data_source_id":"` + id + `","query":"up","chart_type":"line_chart"}`,
+ Contains: `"status"`,
+ },
+ // a query that returned nothing has no result to transform, and the
+ // metric block renders it as no-data.
+ "A Prometheus query with no result at all is no-data": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(prom(nil)),
+ Args: `{"data_source_id":"` + id + `","query":"up","chart_type":"gauge_chart"}`,
+ Contains: `"status":"no-data"`,
+ },
"PostgreSQL raw": {
Type: datasource.TypePostgreSQL,
Runner: dialectRunner(datasource.TypePostgreSQL, pg, my),
@@ -1263,6 +794,12 @@ func Test_querySQL_Execute(t *testing.T) {
Args: `{"data_source_id":"` + id + `","query":"select 1","chart_type":"line_chart"}`,
Contains: `"status"`,
},
+ "A PostgreSQL query with no rows at all is no-data": {
+ Type: datasource.TypePostgreSQL,
+ Runner: dialectRunner(datasource.TypePostgreSQL, pg, my),
+ Args: `{"data_source_id":"` + id + `","query":"empty","chart_type":"line_chart"}`,
+ Contains: `"status":"no-data"`,
+ },
"MySQL raw": {
Type: datasource.TypeMySQL,
Runner: dialectRunner(datasource.TypeMySQL, pg, my),
@@ -1275,13 +812,7 @@ func Test_querySQL_Execute(t *testing.T) {
Args: `{"data_source_id":"` + id + `","query":"select 1","chart_type":"bar_chart"}`,
Contains: `"status"`,
},
- "A PostgreSQL chart query with no rows at all is no-data": {
- Type: datasource.TypePostgreSQL,
- Runner: dialectRunner(datasource.TypePostgreSQL, pg, my),
- Args: `{"data_source_id":"` + id + `","query":"empty","chart_type":"line_chart"}`,
- Contains: `"status":"no-data"`,
- },
- "A MySQL chart query with no rows at all is no-data": {
+ "A MySQL query with no rows at all is no-data": {
Type: datasource.TypeMySQL,
Runner: dialectRunner(datasource.TypeMySQL, pg, my),
Args: `{"data_source_id":"` + id + `","query":"empty","chart_type":"line_chart"}`,
@@ -1317,6 +848,12 @@ func Test_querySQL_Execute(t *testing.T) {
Args: `{"data_source_id":"` + id + `","query":"select 1","from":"2026-08-01T12:00:00Z","to":"2026-08-01T10:00:00Z"}`,
Err: assert.AnError,
},
+ "A data source that hands out no Prometheus client": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(nil),
+ Args: `{"data_source_id":"` + id + `","query":"up"}`,
+ Err: assert.AnError,
+ },
"A data source that hands out no PostgreSQL client": {
Type: datasource.TypePostgreSQL,
Runner: dialectRunner(datasource.TypePostgreSQL, nil, my),
@@ -1329,6 +866,12 @@ func Test_querySQL_Execute(t *testing.T) {
Args: `{"data_source_id":"` + id + `","query":"select 1"}`,
Err: assert.AnError,
},
+ "A failing Prometheus read": {
+ Type: datasource.TypePrometheus,
+ Runner: prometheusRunner(prom(nil)),
+ Args: `{"data_source_id":"` + id + `","query":"boom"}`,
+ Err: assert.AnError,
+ },
"A failing PostgreSQL read": {
Type: datasource.TypePostgreSQL,
Runner: dialectRunner(datasource.TypePostgreSQL, pg, my),
@@ -1351,11 +894,100 @@ func Test_querySQL_Execute(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- runDataSourceCase(t, querySQL{}, NameQuerySQL, c)
+ runDataSourceCase(t, queryDataSource{}, NameQueryDataSource, c)
})
}
}
+func Test_newRawSeries(t *testing.T) {
+ t.Parallel()
+
+ stream := func(points int) *model.SampleStream {
+ s := &model.SampleStream{Metric: model.Metric{"job": "api"}}
+
+ for i := range points {
+ s.Values = append(s.Values, model.SamplePair{Timestamp: model.Time(i), Value: model.SampleValue(i)})
+ }
+
+ return s
+ }
+
+ matrix := func(series, points int) model.Matrix {
+ out := make(model.Matrix, 0, series)
+
+ for range series {
+ out = append(out, stream(points))
+ }
+
+ return out
+ }
+
+ cc := map[string]struct {
+ Input *processor.PrometheusQueryResult
+ Series int
+ Points int
+ Truncated string
+ }{
+ "An answer within the caps is as it was": {
+ Input: &processor.PrometheusQueryResult{Type: model.ValMatrix, Result: matrix(2, 3)},
+ Series: 2,
+ Points: 3,
+ },
+ "Too many series keeps the first ones": {
+ Input: &processor.PrometheusQueryResult{Type: model.ValMatrix, Result: matrix(_maxRawSeries+5, 3)},
+ Series: _maxRawSeries,
+ Points: 3,
+ Truncated: "20 of 25 series, each its latest 100 points at most; set chart_type to describe them all",
+ },
+ "Too many points keeps the latest ones": {
+ Input: &processor.PrometheusQueryResult{Type: model.ValMatrix, Result: matrix(1, _maxRawPoints+50)},
+ Series: 1,
+ Points: _maxRawPoints,
+ Truncated: "1 of 1 series, each its latest 100 points at most; set chart_type to describe them all",
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ got := newRawSeries(c.Input)
+ assert.Equal(t, c.Truncated, got.Truncated)
+
+ m, ok := got.Result.(model.Matrix)
+ require.True(t, ok)
+ require.Len(t, m, c.Series)
+ assert.Len(t, m[0].Values, c.Points)
+ // the points kept are the latest ones.
+ assert.InDelta(t, float64(len(c.Input.Result.(model.Matrix)[0].Values)-1), float64(m[0].Values[len(m[0].Values)-1].Value), 0)
+ })
+ }
+
+ // an answer that is not a range of series has nothing to cap.
+ scalar := &processor.PrometheusQueryResult{Type: model.ValScalar, Result: &model.Scalar{Value: 1}}
+ assert.Equal(t, rawSeries{Type: model.ValScalar, Result: scalar.Result}, newRawSeries(scalar))
+}
+
+func Test_newRawRows(t *testing.T) {
+ t.Parallel()
+
+ rows := func(n int) [][]any {
+ out := make([][]any, 0, n)
+
+ for i := range n {
+ out = append(out, []any{i})
+ }
+
+ return out
+ }
+
+ assert.Equal(t, rawRows{Columns: []string{"a"}, Rows: rows(2)}, newRawRows([]string{"a"}, rows(2)))
+
+ got := newRawRows([]string{"a"}, rows(_maxRawRows+50))
+ assert.Len(t, got.Rows, _maxRawRows)
+ assert.Equal(t, _maxRawRows+50, got.TotalRows)
+}
+
func Test_timeRangeArgs_resolve(t *testing.T) {
t.Parallel()
@@ -1447,10 +1079,6 @@ func Test_newChartPreview(t *testing.T) {
Input *processor.QueryResult
Result chartPreview
}{
- "Nil result reads as no data": {
- Input: nil,
- Result: chartPreview{Status: processor.QueryStatusNoData},
- },
"Status without data carries no series": {
Input: &processor.QueryResult{Status: processor.QueryStatusNoData},
Result: chartPreview{Status: processor.QueryStatusNoData},
diff --git a/server/core/internal/assistant/tools/document.go b/server/core/internal/assistant/tools/document.go
index 723d1796..0f525ab0 100644
--- a/server/core/internal/assistant/tools/document.go
+++ b/server/core/internal/assistant/tools/document.go
@@ -4,11 +4,10 @@ import (
"cmp"
"errors"
"fmt"
- "strings"
"time"
"github.com/guregu/null/v5"
- "github.com/oxynote/oxynote/server/core/internal/assistant/edit"
+ "github.com/oxynote/oxynote/server/core/internal/assistant/markup"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/tag"
"github.com/rs/xid"
@@ -27,25 +26,21 @@ func (listDocumentsArgs) Validate() error {
}
// listDocuments returns the organisation's document tree.
-type listDocuments struct {
- plainSummary
- plainTraits
- plainTitle
-}
+type listDocuments struct{}
// Info returns the tool's model-facing description.
func (listDocuments) Info() Info {
return Info{
Name: NameListDocuments,
- Description: "List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.",
+ Description: "List the organisation's documents as a tree of {id, name, default_branch_id, children}. Use it to find a document by title; search_documents finds content. parent_id narrows it to that document's children.",
Properties: map[string]any{
- "parent_id": map[string]any{"type": "string", "description": "Optional. Return only the direct children of this document. Omit for the full tree."},
+ "parent_id": map[string]any{"type": "string", "description": "Optional. A document whose direct children to list; omit it for the whole tree."},
},
}
}
// Execute lists the documents the model asked for.
-func (listDocuments) Execute(inp Input) (string, error) {
+func (listDocuments) Execute(inp *input) (string, error) {
var in listDocumentsArgs
if err := inp.Decode(&in); err != nil {
@@ -64,7 +59,7 @@ func (listDocuments) Execute(inp Input) (string, error) {
}
if err != nil {
- return "", fmt.Errorf("list_documents: fetch tree: %w", err)
+ return "", fmt.Errorf("fetch tree: %w", err)
}
return result(documentTreeResult{
@@ -101,52 +96,48 @@ type deletedDocumentResult struct {
// getDocumentArgs is what get_document is called with.
type getDocumentArgs struct {
docTarget
+
+ // BlockUID narrows the content to one block. Optional.
+ BlockUID string `json:"block_uid"`
}
-// Validate checks the arguments are complete.
-func (a getDocumentArgs) Validate() error {
- return a.validate()
-}
-
-// getDocument returns one document: its metadata and the ordered rows
-// of its content.
-type getDocument struct {
- plainSummary
- plainTraits
-}
+// getDocument returns one document: its metadata and its content as
+// markup.
+type getDocument struct{}
// Info returns the tool's model-facing description.
func (getDocument) Info() Info {
return Info{
Name: NameGetDocument,
- Description: "Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}] with color a palette name as list_tags reports it, followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.",
+ Description: "Read one document on one branch: its name, icon, parent_id and updated_at, every branch it has as {id, name, protected, default, updated_at}, the tags on the branch read, and content: its blocks as XML, one element per block with its id, in the format insert_blocks describes. Read a document this way before editing it. A protected branch refuses writes, so write to an unprotected one.",
Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "branch_id": map[string]any{"type": "string", "description": "The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write."},
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
+ "block_uid": map[string]any{"type": "string", "description": "Optional. Narrows content to this block and what it holds; an id inside a block, such as a search hit's, reads the block holding it."},
},
Required: []string{"document_id", "branch_id"},
}
}
// Title announces which document is being read.
-func (getDocument) Title(inp DescribeInput) (string, error) {
+func (getDocument) Title(inp DescribeInput) string {
var in getDocumentArgs
if err := inp.Decode(&in); err != nil {
- return "", err
+ return ""
}
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameGetDocument, err)
+ return ""
}
- return "Reading " + doc.Title(), nil
+ return "Reading " + doc.Title()
}
// Execute fetches the branch and summarises its content. The document
// fetch carries the branch's content, so no second read is needed.
-func (getDocument) Execute(inp Input) (string, error) {
+func (getDocument) Execute(inp *input) (string, error) {
var in getDocumentArgs
if err := inp.Decode(&in); err != nil {
@@ -155,17 +146,28 @@ func (getDocument) Execute(inp Input) (string, error) {
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
if err != nil {
- return "", fmt.Errorf("get_document: fetch: %w", err)
+ return "", fmt.Errorf("fetch: %w", err)
}
branches, err := inp.FetchDocumentBranches(in.DocumentID)
if err != nil {
- return "", fmt.Errorf("get_document: %w", err)
+ return "", err
}
tags, err := inp.FetchBranchTags(in.DocumentID, in.BranchID)
if err != nil {
- return "", fmt.Errorf("get_document: fetch tags: %w", err)
+ return "", fmt.Errorf("fetch tags: %w", err)
+ }
+
+ blocks := doc.Content.Content
+
+ if in.BlockUID != "" {
+ b, ok := markup.Readable(blocks, in.BlockUID)
+ if !ok {
+ return "", fmt.Errorf("block %s: %w", in.BlockUID, errUnknownBlock)
+ }
+
+ blocks = []document.Block{b}
}
out := documentResult{
@@ -173,12 +175,10 @@ func (getDocument) Execute(inp Input) (string, error) {
Name: doc.DocumentName,
Icon: doc.Icon,
ParentID: doc.ParentID,
- Protected: doc.Protected,
UpdatedAt: doc.UpdatedAt.UTC().Format(time.RFC3339),
- Branch: branchInfo{ID: doc.BranchID, Name: doc.BranchName, Protected: doc.Protected, Default: doc.Default},
Branches: make([]branchInfo, 0, len(branches)),
Tags: make([]documentTag, 0, len(tags)),
- Blocks: walkDocForAssistant(doc.Content.Content),
+ Content: markup.Render(blocks),
}
for _, tg := range tags {
@@ -212,23 +212,18 @@ type documentResult struct {
// ParentID is the document's parent, absent at the root.
ParentID null.Value[xid.ID] `json:"parent_id,omitzero"`
- // Protected indicates the branch read refuses every write.
- Protected bool `json:"protected"`
-
- // UpdatedAt is when the branch last changed, as RFC3339.
+ // UpdatedAt is when the branch read last changed, as RFC3339.
UpdatedAt string `json:"updated_at"`
- // Branch is the branch the rows were read from.
- Branch branchInfo `json:"branch"`
-
- // Branches is every branch the document has.
+ // Branches is every branch the document has, the one read among
+ // them.
Branches []branchInfo `json:"branches"`
// Tags is every tag the branch read carries.
Tags []documentTag `json:"tags"`
- // Blocks is the branch's content, one row per block.
- Blocks []docSummaryEntry `json:"blocks"`
+ // Content is the branch's blocks, or the one asked for, as markup.
+ Content string `json:"content"`
}
// documentTag is one tag row of get_document.
@@ -294,32 +289,17 @@ func (a createDocumentArgs) Validate() error {
func (createDocument) Info() Info {
return Info{
Name: NameCreateDocument,
- Description: "Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.",
+ Traits: Traits{Write: true},
+ Description: "Create a document and return {document_id, branch_id}, the branch_id being its default branch. It starts with one empty paragraph; fill it with insert_blocks at position end on that branch.",
Properties: map[string]any{
- "name": map[string]any{"type": "string", "description": "Display name for the new document."},
- document.AttrIcon: map[string]any{"type": "string", "description": "Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty."},
- "parent_id": map[string]any{"type": "string", "description": "Optional parent document id. Omit to create at the org root."},
+ "name": map[string]any{"type": "string", "description": "The display name."},
+ document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:file-code-fill. Defaults to mingcute:document-2-fill."},
+ "parent_id": map[string]any{"type": "string", "description": "Optional. The parent document id; omit it for the organisation root."},
},
Required: []string{"name"},
}
}
-// Traits reports a write.
-func (createDocument) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces the document being created.
-func (createDocument) Title(inp DescribeInput) (string, error) {
- var in createDocumentArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- return fmt.Sprintf("Creating %q", in.Name), nil
-}
-
// Summary describes the document the model wants to create.
func (createDocument) Summary(inp DescribeInput) (ActionSummary, error) {
var in createDocumentArgs
@@ -337,7 +317,7 @@ func (createDocument) Summary(inp DescribeInput) (ActionSummary, error) {
}
// Execute creates the document, its maintainer row and its search job.
-func (createDocument) Execute(inp Input) (string, error) {
+func (createDocument) Execute(inp *input) (string, error) {
var in createDocumentArgs
if err := inp.Decode(&in); err != nil {
@@ -353,14 +333,12 @@ func (createDocument) Execute(inp Input) (string, error) {
Name: in.Name,
Icon: icon,
ParentID: in.ParentID,
- }, inp.OrganizationID(), inp.UserID())
+ }, inp.orgID, inp.userID)
if err := inp.CreateDocument(doc); err != nil {
- return "", fmt.Errorf("create_document: %w", err)
+ return "", err
}
- inp.NotifyTreeChange(doc.ParentID)
-
return result(createdDocumentResult{
DocumentID: doc.ID,
BranchID: doc.BranchID,
@@ -389,34 +367,13 @@ type deleteDocument struct{}
func (deleteDocument) Info() Info {
return Info{
Name: NameDeleteDocument,
+ Traits: Traits{Write: true, Destructive: true},
Description: "Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.",
- Properties: map[string]any{"document_id": map[string]any{"type": "string", "description": "The document id to delete."}},
+ Properties: map[string]any{"document_id": map[string]any{"type": "string", "description": _documentIDDescription}},
Required: []string{"document_id"},
}
}
-// Traits reports a destructive write, which stays outside any "approve
-// all" answer.
-func (deleteDocument) Traits() Traits {
- return Traits{Write: true, Destructive: true}
-}
-
-// Title announces which document is being deleted.
-func (deleteDocument) Title(inp DescribeInput) (string, error) {
- var in deleteDocumentArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchDocument(in.DocumentID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameDeleteDocument, err)
- }
-
- return "Deleting " + doc.DocumentName, nil
-}
-
// Summary describes the document the model wants to delete.
func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
var in deleteDocumentArgs
@@ -427,7 +384,7 @@ func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
doc, err := inp.FetchDocument(in.DocumentID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameDeleteDocument, err)
+ return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
}
summary := "Delete " + doc.DocumentName
@@ -435,7 +392,7 @@ func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
// the delete cascades, so a card naming only the document would
// have the user approve a subtree they were never shown.
if n, err := inp.DescendantCount(in.DocumentID); err == nil && n > 0 {
- summary += fmt.Sprintf(" and the %s nested under it", pluralPages(n))
+ summary += fmt.Sprintf(" and the %s nested under it", countPhrase(n, "page"))
}
return ActionSummary{
@@ -446,37 +403,18 @@ func (deleteDocument) Summary(inp DescribeInput) (ActionSummary, error) {
}, nil
}
-// pluralPages renders a nested-document count for the confirm card.
-func pluralPages(n int) string {
- if n == 1 {
- return "1 page"
- }
-
- return fmt.Sprintf("%d pages", n)
-}
-
// Execute deletes the document and refreshes the tree.
-func (deleteDocument) Execute(inp Input) (string, error) {
+func (deleteDocument) Execute(inp *input) (string, error) {
var in deleteDocumentArgs
if err := inp.Decode(&in); err != nil {
return "", err
}
- // capture the parent before the row goes away so we can scope the
- // tree-change notification to the affected subtree.
- var parentID null.Value[xid.ID]
-
- if doc, ferr := inp.FetchDocument(in.DocumentID); ferr == nil && doc != nil {
- parentID = doc.ParentID
- }
-
if err := inp.DeleteDocument(in.DocumentID); err != nil {
- return "", fmt.Errorf("delete_document: delete: %w", err)
+ return "", fmt.Errorf("delete: %w", err)
}
- inp.NotifyTreeChange(parentID)
-
return result(deletedDocumentResult{
DocumentID: in.DocumentID,
Deleted: true,
@@ -507,7 +445,7 @@ func (a updateDocumentArgs) Validate() error {
}
if a.Name == "" && a.Icon == "" && a.ParentID == nil {
- return errors.New("update_document needs at least one of name, icon or parent_id")
+ return errors.New("give at least one of name, icon or parent_id")
}
if a.ParentID != nil && *a.ParentID != "" {
@@ -568,38 +506,18 @@ type updateDocument struct{}
func (updateDocument) Info() Info {
return Info{
Name: NameUpdateDocument,
- Description: "Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.",
+ Traits: Traits{Write: true},
+ Description: "Change a document's name, icon or parent; give only what changes, and a call changing nothing is refused. Content is untouched, and a parent that is the document itself or one of its descendants is refused. Returns {document_id} with what changed.",
Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "name": map[string]any{"type": "string", "description": "Optional. The new display name; omit to keep the current one."},
- document.AttrIcon: map[string]any{"type": "string", "description": "Optional. The new icon identifier; omit to keep the current one."},
- "parent_id": map[string]any{"type": "string", "description": "Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is."},
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "name": map[string]any{"type": "string", "description": "Optional. The new display name."},
+ document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:rocket-fill."},
+ "parent_id": map[string]any{"type": "string", "description": "Optional. The new parent document id, or an empty string for the organisation root."},
},
Required: []string{"document_id"},
}
}
-// Traits reports a write.
-func (updateDocument) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which document is being updated.
-func (updateDocument) Title(inp DescribeInput) (string, error) {
- var in updateDocumentArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchDocument(in.DocumentID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameUpdateDocument, err)
- }
-
- return "Updating " + doc.DocumentName, nil
-}
-
// Summary lists exactly the changes the model asked for.
func (updateDocument) Summary(inp DescribeInput) (ActionSummary, error) {
var in updateDocumentArgs
@@ -610,28 +528,20 @@ func (updateDocument) Summary(inp DescribeInput) (ActionSummary, error) {
doc, err := inp.FetchDocument(in.DocumentID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameUpdateDocument, err)
- }
-
- changes := in.changes(doc.DocumentName)
- summary := strings.ToUpper(changes[0][:1]) + changes[0][1:]
-
- if len(changes) > 1 {
- summary = fmt.Sprintf("%s and %s", strings.Join(changes[:len(changes)-1], ", "), changes[len(changes)-1])
- summary = strings.ToUpper(summary[:1]) + summary[1:]
+ return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
}
return ActionSummary{
Tool: NameUpdateDocument,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
- Summary: summary,
+ Summary: sentence(in.changes(doc.DocumentName)),
}, nil
}
// Execute applies the name and icon through the live document, then
// re-parents it, and tells the tree subscribers what moved.
-func (updateDocument) Execute(inp Input) (string, error) {
+func (updateDocument) Execute(inp *input) (string, error) {
var in updateDocumentArgs
if err := inp.Decode(&in); err != nil {
@@ -640,45 +550,19 @@ func (updateDocument) Execute(inp Input) (string, error) {
doc, err := inp.FetchDocument(in.DocumentID)
if err != nil {
- return "", fmt.Errorf("update_document: fetch document: %w", err)
+ return "", fmt.Errorf("fetch document: %w", err)
}
- var ops []edit.Operation
-
- if in.Name != "" {
- ops = append(ops, edit.SetName(in.Name))
+ if err := inp.RenameDocument(doc, in.Name, in.Icon); err != nil {
+ return "", err
}
- if in.Icon != "" {
- ops = append(ops, edit.SetIcon(in.Icon))
- }
-
- if len(ops) > 0 {
- if err := inp.ApplyEdit(in.DocumentID, doc.BranchID, ops); err != nil {
+ if parent, moved := in.parent(); moved {
+ if err := inp.MoveDocument(doc, parent); err != nil {
return "", err
}
}
- parent, moved := in.parent()
- if moved {
- if err := inp.MoveDocument(doc, parent); err != nil {
- return "", fmt.Errorf("update_document: %w", err)
- }
- }
-
- // a rename or icon change shows in the document's own row; a move
- // changes the shape of the source and destination subtrees, and
- // when those are the same parent one notification covers both.
- switch {
- case moved && doc.ParentID != parent:
- inp.NotifyTreeChange(doc.ParentID)
- inp.NotifyTreeChange(parent)
- case moved:
- inp.NotifyTreeChange(parent)
- default:
- inp.NotifyTreeChangeForDocument(in.DocumentID)
- }
-
return result(updatedDocumentResult(in))
}
@@ -711,10 +595,6 @@ type docTreeNode struct {
// summariesToTree converts the document package's nested Summary tree
// into the snake_case shape returned by list_documents.
func summariesToTree(ss document.Summaries) []docTreeNode {
- if len(ss) == 0 {
- return nil
- }
-
out := make([]docTreeNode, 0, len(ss))
for _, s := range ss {
diff --git a/server/core/internal/assistant/tools/document_test.go b/server/core/internal/assistant/tools/document_test.go
index fa47edc7..629b0511 100644
--- a/server/core/internal/assistant/tools/document_test.go
+++ b/server/core/internal/assistant/tools/document_test.go
@@ -50,6 +50,8 @@ func Test_listDocuments_Info(t *testing.T) {
info := listDocuments{}.Info()
+ assert.Equal(t, Traits{}, info.Traits)
+
assert.Equal(t, NameListDocuments, info.Name)
assert.NotEmpty(t, info.Description)
assert.Contains(t, info.Properties, "parent_id")
@@ -58,20 +60,6 @@ func Test_listDocuments_Info(t *testing.T) {
assert.Empty(t, info.Required)
}
-func Test_listDocuments_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{}, listDocuments{}.Traits())
-}
-
-func Test_listDocuments_Title(t *testing.T) {
- t.Parallel()
-
- got, err := listDocuments{}.Title(testInput(testDeps(nil, nil, nil), NameListDocuments, `{}`))
- require.NoError(t, err)
- assert.Empty(t, got)
-}
-
func Test_listDocuments_Execute(t *testing.T) {
t.Parallel()
@@ -162,57 +150,37 @@ func Test_listDocuments_Execute(t *testing.T) {
}
}
-func Test_getDocumentArgs_Validate(t *testing.T) {
- t.Parallel()
-
- assertValidate(t, getDocumentArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID}, map[string]Args{
- "document_id": getDocumentArgs{},
- })
-}
-
func Test_getDocument_Info(t *testing.T) {
t.Parallel()
info := getDocument{}.Info()
+ assert.Equal(t, Traits{}, info.Traits)
+
assert.Equal(t, NameGetDocument, info.Name)
assert.NotEmpty(t, info.Description)
assert.Equal(t, []string{"document_id", "branch_id"}, info.Required)
assert.Contains(t, info.Properties, "branch_id")
}
-func Test_getDocument_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{}, getDocument{}.Traits())
-}
-
func Test_getDocument_Title(t *testing.T) {
t.Parallel()
- got, err := getDocument{}.Title(testInput(
+ assert.Equal(t, "Reading Runbook", getDocument{}.Title(testInput(
testDeps(stubDocumentDB(), nil, nil), NameGetDocument,
`{`+targetArgs(_stubMainBranchID)+`}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Reading Runbook", got)
+ )))
// a branch other than the default one is named.
- got, err = getDocument{}.Title(testInput(
+ assert.Equal(t, "Reading Runbook on branch draft", getDocument{}.Title(testInput(
testDeps(stubDocumentDB(), nil, nil), NameGetDocument,
`{`+targetArgs(_stubBranchID)+`}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Reading Runbook on branch draft", got)
+ )))
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = getDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameGetDocument, requiredArgs(t, NameGetDocument)))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = getDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameGetDocument, `{`))
- require.Error(t, err)
+ // a document that does not resolve, or arguments that cannot be
+ // read, make no line.
+ assert.Empty(t, getDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameGetDocument, requiredArgs(t, NameGetDocument))))
+ assert.Empty(t, getDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameGetDocument, `{`)))
}
func Test_getDocument_Execute(t *testing.T) {
@@ -283,26 +251,23 @@ func Test_getDocument_Execute(t *testing.T) {
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
Err: assert.AnError,
},
- "Metadata, branches and rows are returned": {
+ "Metadata, branches and content are returned": {
DB: nested,
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
Contains: []string{
`"name":"Runbook"`,
`"icon":"lucide:rocket"`,
`"parent_id":"` + parentID.String() + `"`,
- `"protected":true`,
- `"branch":{"id":"` + _stubMainBranchID.String() + `","name":"main","protected":true,"default":true}`,
`"branches":[{"id":"` + _stubMainBranchID.String() + `","name":"main","protected":false,"default":true,"updated_at":`,
`{"id":"` + _stubBranchID.String() + `","name":"draft","protected":false,"default":false,"updated_at":`,
`"tags":[]`,
- `"uid":"a"`,
- `"kind":"paragraph"`,
+ `"content":"`,
},
},
"Another branch is read": {
DB: stubContentDB(nil),
Args: `{` + targetArgs(_stubBranchID) + `}`,
- Contains: []string{`"branch":{"id":"` + _stubBranchID.String() + `","name":"draft","protected":false,"default":false}`, `"uid":"a"`},
+ Contains: []string{`
hello
`},
},
"Tags of the branch read are listed": {
DB: tagged,
@@ -311,11 +276,22 @@ func Test_getDocument_Execute(t *testing.T) {
`"tags":[{"id":"` + _testTagID.String() + `","name":"Production","color":"green"}]`,
},
},
+ "Block narrows the content to it": {
+ DB: stubContentDB(nil),
+ Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"cp"}`,
+ Contains: []string{`"content":"inside
\n"`},
+ Omits: []string{`id=\"a\"`},
+ },
+ "Block the document does not hold": {
+ DB: stubContentDB(nil),
+ Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"zzz"}`,
+ Err: assert.AnError,
+ },
"Root document omits the parent": {
DB: stubContentDB(nil),
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
- Contains: []string{`"name":"Runbook"`, `"protected":false`},
- Omits: []string{`"parent_id"`, `"branch_id"`},
+ Contains: []string{`"name":"Runbook"`},
+ Omits: []string{`"parent_id"`, `"branch_id"`, `"branch":`},
},
}
@@ -354,30 +330,13 @@ func Test_createDocument_Info(t *testing.T) {
info := createDocument{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameCreateDocument, info.Name)
assert.Equal(t, []string{"name"}, info.Required)
assert.Contains(t, info.Properties, document.AttrIcon)
}
-func Test_createDocument_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, createDocument{}.Traits())
-}
-
-func Test_createDocument_Title(t *testing.T) {
- t.Parallel()
-
- d := testDeps(nil, nil, nil)
-
- got, err := createDocument{}.Title(testInput(d, NameCreateDocument, `{"name":"Runbook"}`))
- require.NoError(t, err)
- assert.Equal(t, `Creating "Runbook"`, got)
-
- _, err = createDocument{}.Title(testInput(d, NameCreateDocument, `{}`))
- require.Error(t, err)
-}
-
func Test_createDocument_Summary(t *testing.T) {
t.Parallel()
@@ -477,38 +436,14 @@ func Test_deleteDocument_Info(t *testing.T) {
info := deleteDocument{}.Info()
+ // a delete stays outside any "approve all" answer.
+ assert.Equal(t, Traits{Write: true, Destructive: true}, info.Traits)
+
assert.Equal(t, NameDeleteDocument, info.Name)
assert.Contains(t, info.Description, "cannot be restored")
assert.Equal(t, []string{"document_id"}, info.Required)
}
-func Test_deleteDocument_Traits(t *testing.T) {
- t.Parallel()
-
- // a delete stays outside any "approve all" answer.
- assert.Equal(t, Traits{Write: true, Destructive: true}, deleteDocument{}.Traits())
-}
-
-func Test_deleteDocument_Title(t *testing.T) {
- t.Parallel()
-
- got, err := deleteDocument{}.Title(testInput(
- testDeps(stubDocumentDB(), nil, nil), NameDeleteDocument,
- `{"document_id":"`+_testDocID.String()+`"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Deleting Runbook", got)
-
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = deleteDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameDeleteDocument, requiredArgs(t, NameDeleteDocument)))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = deleteDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameDeleteDocument, `{`))
- require.Error(t, err)
-}
-
func Test_deleteDocument_Summary(t *testing.T) {
t.Parallel()
@@ -593,7 +528,7 @@ func Test_deleteDocument_Execute(t *testing.T) {
// with deleteErr.
stubDeleteDB := func(db *DBMock, deleteErr error) *DBMock {
if db == nil {
- db = &DBMock{}
+ db = stubDocumentDB()
}
db.BeginTxFunc = func(_ context.Context, dest any) error {
@@ -751,6 +686,8 @@ func Test_updateDocument_Info(t *testing.T) {
info := updateDocument{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameUpdateDocument, info.Name)
assert.Equal(t, []string{"document_id"}, info.Required)
assert.Contains(t, info.Properties, "name")
@@ -758,33 +695,6 @@ func Test_updateDocument_Info(t *testing.T) {
assert.Contains(t, info.Properties, "parent_id")
}
-func Test_updateDocument_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, updateDocument{}.Traits())
-}
-
-func Test_updateDocument_Title(t *testing.T) {
- t.Parallel()
-
- got, err := updateDocument{}.Title(testInput(
- testDeps(stubDocumentDB(), nil, nil), NameUpdateDocument,
- `{"document_id":"`+_testDocID.String()+`","name":"Playbook"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Updating Runbook", got)
-
- // the document it names has to resolve; the failure is passed on
- // rather than described around.
- _, err = updateDocument{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateDocument,
- `{"document_id":"`+_testDocID.String()+`","name":"Playbook"}`))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = updateDocument{}.Title(testInput(testDeps(stubDocumentDB(), nil, nil), NameUpdateDocument, `{`))
- require.Error(t, err)
-}
-
func Test_updateDocument_Summary(t *testing.T) {
t.Parallel()
@@ -943,7 +853,7 @@ func Test_updateDocument_Execute(t *testing.T) {
Applier: stubApplier(),
Args: doc + `,"name":"Playbook","parent_id":"` + parentID.String() + `"}`,
Ops: 1,
- Notify: 2,
+ Notify: 3,
Result: `{"document_id":"` + _testDocID.String() + `","name":"Playbook","parent_id":"` + parentID.String() + `"}`,
},
}
@@ -982,7 +892,7 @@ func Test_updateDocument_Execute(t *testing.T) {
func Test_summariesToTree(t *testing.T) {
t.Parallel()
- assert.Nil(t, summariesToTree(nil))
+ assert.Equal(t, []docTreeNode{}, summariesToTree(nil))
id := xid.New()
child := xid.New()
diff --git a/server/core/internal/assistant/tools/docwalk.go b/server/core/internal/assistant/tools/docwalk.go
deleted file mode 100644
index c9027ee0..00000000
--- a/server/core/internal/assistant/tools/docwalk.go
+++ /dev/null
@@ -1,271 +0,0 @@
-package tools
-
-import (
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
- "github.com/oxynote/oxynote/server/core/internal/document"
-)
-
-// _walkerKinds names the list wrappers the canonical model has no type
-// for. The summary reports them anyway, since it describes the hierarchy
-// the AI targets its edits at.
-var _walkerKinds = map[document.BlockNodeType]string{
- document.BlockNodeListItem: "list_item",
- document.BlockNodeTaskItem: "task_item",
-}
-
-// docSummaryEntry is one row in the assistant-facing document
-// summary. Every directly editable block surfaces with its uid so
-// the assistant can target edits or deletes precisely.
-type docSummaryEntry struct {
- // UID is the block's uid attribute.
- UID string `json:"uid"`
-
- // Kind is the canonical block kind ("paragraph", "heading",
- // "callout", "bullet_list", "list_item", "split_doc", …). For
- // nested children we emit the inner kind (e.g. "list_item")
- // alongside ParentUID so the AI can reason about hierarchy.
- Kind string `json:"kind"`
-
- // Text is the flattened text of the block's subtree, useful for
- // the AI to identify the block by its content.
- Text string `json:"text,omitempty"`
-
- // Depth is the nesting level in this summary (0 = top-level
- // document children).
- Depth int `json:"depth"`
-
- // ParentUID is the uid of the enclosing block when the entry
- // is nested. Empty at depth 0.
- ParentUID string `json:"parent_uid,omitempty"`
-
- // Attrs surfaces the small per-kind attrs the AI might want
- // (heading.level, callout.icon, code.language, list task
- // item.checked). Opaque attrs (metric configs) are omitted.
- Attrs map[string]any `json:"attrs,omitempty"`
-
- // HasChildren marks an entry holding addressable blocks the walk
- // does not emit — a sublist under a list item, the internals of a
- // macro. Their text is folded into this entry's Text, so without
- // the flag the summary reads as though this row were the whole of
- // it, and the uids an edit would need are invisible.
- HasChildren bool `json:"has_children,omitempty"`
-}
-
-// walkDocForAssistant builds the flat list of summary entries the
-// assistant uses. Each entry preserves document order; nested
-// blocks follow their parent with depth/parent_uid set so the AI
-// can reconstruct the hierarchy if needed.
-//
-// Macro internals (the LeftSide/RightSide wrappers of
-// splitDocumentation, the four-deep nesting inside a parameter
-// list) are not exposed: the AI sees the macro as a single entry
-// and drills into it via read_block when it needs the detail.
-func walkDocForAssistant(blocks []document.Block) []docSummaryEntry {
- w := &docWalker{}
- w.walkLevel(blocks, 0, "")
-
- return w.out
-}
-
-// blockRows builds the summary rows for one block and everything nested
-// in it: what a write returns for the block it wrote or touched. Depth
-// is relative to the block, so the block itself sits at 0 with no
-// parent. A kind the document walk never lists on its own (titled_code
-// lives only inside split_doc.right) still gets its one row here, since
-// the caller just wrote it and needs its uid back.
-func blockRows(b document.Block) []docSummaryEntry {
- w := &docWalker{}
- w.walkLevel([]document.Block{b}, 0, "")
-
- if len(w.out) == 0 {
- if uid, ok := b.UID(); ok && uid != "" {
- w.emit(b, uid, 0, "", hasAddressableChildren(b))
- }
- }
-
- return w.out
-}
-
-// docWalker accumulates the summary entries walkDocForAssistant
-// produces as it recurses through the document tree.
-type docWalker struct {
- // out collects the emitted entries in document order.
- out []docSummaryEntry
-}
-
-// walkLevel emits one entry per block at the current level, then
-// recurses into children for compound kinds whose children are
-// individually editable (lists, callout, blockquote, metric_grid).
-// Macro kinds (split_doc, param_list) emit a single entry without
-// descending — their internals are not editable as standalone
-// blocks.
-func (w *docWalker) walkLevel(blocks []document.Block, depth int, parentUID string) {
- for _, b := range blocks {
- uid, _ := b.UID()
- if uid == "" {
- // A block without a uid can't be referenced from any
- // edit tool, so it's not useful to surface.
- continue
- }
-
- switch b.Type {
- // list items surface flat because their visible content is
- // the first child paragraph; deeper recursion isn't useful.
- // The macro kinds (split_doc, param_list) also surface as a
- // single entry without descending — their internals are not
- // editable as standalone blocks, so the AI drills into them
- // via read_block. Titled code blocks never reach this walk:
- // they exist only inside split_doc.right.
- case document.BlockNodeParagraph,
- document.BlockNodeHeading,
- document.BlockNodeCodeBlock,
- document.BlockNodeMermaidBlock,
- document.BlockNodeHorizontalRule,
- document.BlockNodeImageBlock,
- document.BlockNodeFileBlock,
- document.BlockNodeFigmaBlock,
- document.BlockNodeMetricBlock,
- document.BlockNodeListItem,
- document.BlockNodeTaskItem,
- document.BlockNodeSplitDoc,
- document.BlockNodeParamList:
- w.emit(b, uid, depth, parentUID, hasAddressableChildren(b))
-
- case document.BlockNodeBulletList,
- document.BlockNodeOrderedList,
- document.BlockNodeTaskList,
- document.BlockNodeCalloutBlock,
- document.BlockNodeBlockquote,
- document.BlockNodeMetricGrid:
- // the walk lists this block's children itself, so there is
- // nothing hidden below it to point the reader at.
- w.emit(b, uid, depth, parentUID, false)
- w.walkLevel(b.Content, depth+1, uid)
- default:
- }
- }
-}
-
-// emit creates the standard summary entry for a block and appends it to
-// the walker's output. hasChildren says whether the block holds
-// addressable blocks this walk is not going to emit.
-func (w *docWalker) emit(
- b document.Block,
- uid string,
- depth int,
- parentUID string,
- hasChildren bool,
-) {
- entry := docSummaryEntry{
- UID: uid,
- Kind: canonicalKindForPM(b.Type),
- Text: b.Flatten(),
- Depth: depth,
- ParentUID: parentUID,
- Attrs: summaryAttrs(b),
- HasChildren: hasChildren,
- }
-
- w.out = append(w.out, entry)
-}
-
-// hasAddressableChildren reports whether the block holds addressable
-// blocks beyond the text the entry already shows. A leading paragraph
-// is that text — every list item has one — so it is not what the flag
-// is for; anything after it is structure the summary does not list.
-func hasAddressableChildren(b document.Block) bool {
- for i, cb := range b.Content {
- if i == 0 && cb.Type == document.BlockNodeParagraph {
- continue
- }
-
- if uid, ok := cb.UID(); ok && uid != "" {
- return true
- }
-
- if hasAddressableChildren(cb) {
- return true
- }
- }
-
- return false
-}
-
-// canonicalKindForPM maps a ProseMirror node type to the canonical kind
-// name the assistant uses elsewhere. Keeping the AI on one vocabulary
-// (snake_case canonical names) avoids surprises when it reads a summary,
-// then writes via tools that use the canonical names.
-//
-// listItem and taskItem have no canonical type — the canonical model folds
-// them into their list's typed fields — but the summary names them anyway,
-// since it reports the hierarchy the AI targets its edits at.
-func canonicalKindForPM(pm document.BlockNodeType) string {
- if kind, ok := _walkerKinds[pm]; ok {
- return kind
- }
-
- t, ok := block.CanonicalType(pm)
- if !ok {
- return string(pm)
- }
-
- return string(t)
-}
-
-// summaryAttrs filters the block's raw PM attrs down to the small
-// per-kind subset the AI cares about for reads. uid is dropped
-// (already on the entry); opaque metric configurations are dropped
-// because they would balloon the payload and the AI never edits
-// them by hand anyway.
-func summaryAttrs(b document.Block) map[string]any {
- switch b.Type {
- case document.BlockNodeHeading:
- if v, ok := b.Attrs[document.AttrLevel]; ok {
- return map[string]any{document.AttrLevel: v}
- }
- case document.BlockNodeCalloutBlock:
- if v, ok := b.Attrs[document.AttrIcon]; ok {
- return map[string]any{document.AttrIcon: v}
- }
- case document.BlockNodeCodeBlock:
- if v, ok := b.Attrs[document.AttrLanguage]; ok && v != "" {
- return map[string]any{document.AttrLanguage: v}
- }
- case document.BlockNodeTaskItem:
- if v, ok := b.Attrs[document.AttrChecked]; ok {
- return map[string]any{document.AttrChecked: v}
- }
- case document.BlockNodeImageBlock:
- return presentAttrs(b, document.AttrSrc, document.AttrAlt, document.AttrTitle, document.AttrWidth)
- case document.BlockNodeFileBlock:
- return presentAttrs(b, document.AttrName, document.AttrContentType, document.AttrSize, document.AttrSrc)
- case document.BlockNodeFigmaBlock:
- return presentAttrs(b, document.AttrSrc, document.AttrWidth, document.AttrHeight)
- case document.BlockNodeSplitDoc:
- if a, ok := b.Attrs.Get(document.AttrInversed); ok && a.Bool() {
- return map[string]any{document.AttrInversed: true}
- }
- default:
- return nil
- }
-
- return nil
-}
-
-// presentAttrs picks the named attrs the block carries with a value,
-// or nil when it carries none of them.
-func presentAttrs(b document.Block, keys ...string) map[string]any {
- out := map[string]any{}
-
- for _, k := range keys {
- if v, ok := b.Attrs[k]; ok && v != nil && v != "" {
- out[k] = v
- }
- }
-
- if len(out) == 0 {
- return nil
- }
-
- return out
-}
diff --git a/server/core/internal/assistant/tools/docwalk_test.go b/server/core/internal/assistant/tools/docwalk_test.go
deleted file mode 100644
index b19417de..00000000
--- a/server/core/internal/assistant/tools/docwalk_test.go
+++ /dev/null
@@ -1,356 +0,0 @@
-package tools
-
-import (
- "maps"
- "testing"
-
- "github.com/oxynote/oxynote/server/core/internal/document"
- "github.com/stretchr/testify/assert"
-)
-
-// pmBlock builds a document.Block with a uid attr plus optional
-// extra attrs and children.
-func pmBlock(tp document.BlockNodeType, uid string, attrs map[string]any, children ...document.Block) document.Block {
- all := document.Attributes{}
- if uid != "" {
- all["uid"] = uid
- }
-
- maps.Copy(all, attrs)
-
- if len(all) == 0 {
- all = nil
- }
-
- return document.Block{Type: tp, Attrs: all, Content: children}
-}
-
-// pmText builds an inline text node.
-func pmText(text string) document.Block {
- return document.Block{Type: document.BlockNodeText, Text: text}
-}
-
-func Test_walkDocForAssistant(t *testing.T) {
- cc := map[string]struct {
- Blocks []document.Block
- Result []docSummaryEntry
- }{
- "Simple blocks surface flat": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("hello")),
- pmBlock(document.BlockNodeHeading, "h1", map[string]any{"level": 2}, pmText("Title")),
- },
- Result: []docSummaryEntry{
- {UID: "p1", Kind: "paragraph", Text: "hello"},
- {UID: "h1", Kind: "heading", Text: "Title", Attrs: map[string]any{"level": 2}},
- },
- },
- "Lists descend into their items": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeBulletList, "l1", nil,
- pmBlock(document.BlockNodeListItem, "li1", nil,
- pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("one")),
- ),
- ),
- },
- Result: []docSummaryEntry{
- {UID: "l1", Kind: "bullet_list", Text: "one"},
- {UID: "li1", Kind: "list_item", Text: "one", Depth: 1, ParentUID: "l1"},
- },
- },
- "List item holding a sublist is flagged, since the walk stops there": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeBulletList, "l1", nil,
- pmBlock(document.BlockNodeListItem, "li1", nil,
- pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("parent")),
- pmBlock(document.BlockNodeBulletList, "l2", nil,
- pmBlock(document.BlockNodeListItem, "li2", nil,
- pmBlock(document.BlockNodeParagraph, "p2", nil, pmText("child")),
- ),
- ),
- ),
- ),
- },
- Result: []docSummaryEntry{
- {UID: "l1", Kind: "bullet_list", Text: "parent child"},
- {
- UID: "li1",
- Kind: "list_item",
- Text: "parent child",
- Depth: 1,
- ParentUID: "l1",
- HasChildren: true,
- },
- },
- },
- "Task list surfaces checked attr on items": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeTaskList, "t1", nil,
- pmBlock(document.BlockNodeTaskItem, "ti1", map[string]any{"checked": true},
- pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("done")),
- ),
- ),
- },
- Result: []docSummaryEntry{
- {UID: "t1", Kind: "task_list", Text: "done"},
- {UID: "ti1", Kind: "task_item", Text: "done", Depth: 1, ParentUID: "t1", Attrs: map[string]any{"checked": true}},
- },
- },
- "Blockquote descends into its children": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeBlockquote, "b1", nil,
- pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("quoted")),
- ),
- },
- Result: []docSummaryEntry{
- {UID: "b1", Kind: "blockquote", Text: "quoted"},
- {UID: "p1", Kind: "paragraph", Text: "quoted", Depth: 1, ParentUID: "b1"},
- },
- },
- "Split doc emits a single entry without descending": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeSplitDoc, "s1", map[string]any{"inversed": true},
- pmBlock(document.BlockNodeSplitDocLeft, "sl1", nil,
- pmBlock(document.BlockNodeHeading, "h1", map[string]any{"level": 1}, pmText("API")),
- ),
- ),
- },
- Result: []docSummaryEntry{
- {UID: "s1", Kind: "split_doc", Text: "API", Attrs: map[string]any{"inversed": true}, HasChildren: true},
- },
- },
- "Param list emits a single entry without descending": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeParamList, "pl1", nil,
- pmBlock(document.BlockNodeParamListHeader, "plh1", nil, pmText("Body")),
- ),
- },
- Result: []docSummaryEntry{
- {UID: "pl1", Kind: "split_doc_param_list", Text: "Body", HasChildren: true},
- },
- },
- "Blocks without uid are skipped": {
- Blocks: []document.Block{
- pmBlock(document.BlockNodeParagraph, "", nil, pmText("anonymous")),
- pmBlock(document.BlockNodeParagraph, "p1", nil, pmText("named")),
- },
- Result: []docSummaryEntry{
- {UID: "p1", Kind: "paragraph", Text: "named"},
- },
- },
- "Unknown node types are skipped": {
- Blocks: []document.Block{
- pmBlock("weirdNode", "w1", nil),
- pmBlock(document.BlockNodeCodeBlock, "c1", map[string]any{"language": "go"}, pmText("x := 1")),
- },
- Result: []docSummaryEntry{
- {UID: "c1", Kind: "code", Text: "x := 1", Attrs: map[string]any{"language": "go"}},
- },
- },
- "Empty document yields no entries": {
- Blocks: nil,
- Result: nil,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, walkDocForAssistant(c.Blocks))
- })
- }
-}
-
-func Test_canonicalKindForPM(t *testing.T) {
- cc := map[string]struct {
- PM document.BlockNodeType
- Result string
- }{
- "Paragraph": {PM: document.BlockNodeParagraph, Result: "paragraph"},
- "Heading": {PM: document.BlockNodeHeading, Result: "heading"},
- "Blockquote": {PM: document.BlockNodeBlockquote, Result: "blockquote"},
- "Bullet list": {PM: document.BlockNodeBulletList, Result: "bullet_list"},
- "Ordered list": {PM: document.BlockNodeOrderedList, Result: "ordered_list"},
- "Task list": {PM: document.BlockNodeTaskList, Result: "task_list"},
- "List item": {PM: document.BlockNodeListItem, Result: "list_item"},
- "Task item": {PM: document.BlockNodeTaskItem, Result: "task_item"},
- "Callout": {PM: document.BlockNodeCalloutBlock, Result: "callout"},
- "Code": {PM: document.BlockNodeCodeBlock, Result: "code"},
- "Titled code": {PM: document.BlockNodeTitledCodeBlock, Result: "titled_code"},
- "Mermaid": {PM: document.BlockNodeMermaidBlock, Result: "mermaid"},
- "Horizontal rule": {PM: document.BlockNodeHorizontalRule, Result: "horizontal_rule"},
- "Image": {PM: document.BlockNodeImageBlock, Result: "image"},
- "File": {PM: document.BlockNodeFileBlock, Result: "file"},
- "Figma": {PM: document.BlockNodeFigmaBlock, Result: "figma"},
- "Metric": {PM: document.BlockNodeMetricBlock, Result: "metric"},
- "Metric grid": {PM: document.BlockNodeMetricGrid, Result: "metric_grid"},
- "Split doc": {PM: document.BlockNodeSplitDoc, Result: "split_doc"},
- "Param list": {PM: document.BlockNodeParamList, Result: "split_doc_param_list"},
- "Unknown falls through": {PM: "weirdNode", Result: "weirdNode"},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, canonicalKindForPM(c.PM))
- })
- }
-}
-
-func Test_presentAttrs(t *testing.T) {
- cc := map[string]struct {
- Input document.Block
- Keys []string
- Result map[string]any
- }{
- "Named attrs with values are kept": {
- Input: pmBlock(document.BlockNodeFileBlock, "f", map[string]any{"name": "a.zip", "size": 1, "other": "x"}),
- Keys: []string{"name", "size"},
- Result: map[string]any{"name": "a.zip", "size": 1},
- },
- "Empty and nil values are dropped": {
- Input: pmBlock(document.BlockNodeFileBlock, "f", map[string]any{"name": "", "size": nil, "src": "http://f"}),
- Keys: []string{"name", "size", "src"},
- Result: map[string]any{"src": "http://f"},
- },
- "Nothing present": {
- Input: pmBlock(document.BlockNodeFileBlock, "f", nil),
- Keys: []string{"name"},
- Result: nil,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, presentAttrs(c.Input, c.Keys...))
- })
- }
-}
-
-func Test_summaryAttrs(t *testing.T) {
- cc := map[string]struct {
- Input document.Block
- Result map[string]any
- }{
- "Heading level": {
- Input: pmBlock(document.BlockNodeHeading, "h", map[string]any{"level": 3}),
- Result: map[string]any{"level": 3},
- },
- "Heading without level": {
- Input: pmBlock(document.BlockNodeHeading, "h", nil),
- Result: nil,
- },
- "Callout icon": {
- Input: pmBlock(document.BlockNodeCalloutBlock, "c", map[string]any{"icon": "lucide:warning"}),
- Result: map[string]any{"icon": "lucide:warning"},
- },
- "Code language": {
- Input: pmBlock(document.BlockNodeCodeBlock, "c", map[string]any{"language": "go"}),
- Result: map[string]any{"language": "go"},
- },
- "Code with empty language": {
- Input: pmBlock(document.BlockNodeCodeBlock, "c", map[string]any{"language": ""}),
- Result: nil,
- },
- "Task item checked": {
- Input: pmBlock(document.BlockNodeTaskItem, "t", map[string]any{"checked": false}),
- Result: map[string]any{"checked": false},
- },
- "Image attrs filtered": {
- Input: pmBlock(document.BlockNodeImageBlock, "i", map[string]any{
- "src": "http://x", "alt": "", "width": 100, "opaque": "dropped",
- }),
- Result: map[string]any{"src": "http://x", "width": 100},
- },
- "Image without usable attrs": {
- Input: pmBlock(document.BlockNodeImageBlock, "i", nil),
- Result: nil,
- },
- "File attrs filtered": {
- Input: pmBlock(document.BlockNodeFileBlock, "f", map[string]any{
- "src": "http://f", "name": "notes.zip", "contentType": "application/zip", "size": 2048, "uploading": false,
- }),
- Result: map[string]any{"src": "http://f", "name": "notes.zip", "contentType": "application/zip", "size": 2048},
- },
- "File without usable attrs": {
- Input: pmBlock(document.BlockNodeFileBlock, "f", nil),
- Result: nil,
- },
- "Figma attrs filtered": {
- Input: pmBlock(document.BlockNodeFigmaBlock, "f", map[string]any{
- "src": "http://f", "height": 200,
- }),
- Result: map[string]any{"src": "http://f", "height": 200},
- },
- "Figma without usable attrs": {
- Input: pmBlock(document.BlockNodeFigmaBlock, "f", nil),
- Result: nil,
- },
- "Split doc inversed": {
- Input: pmBlock(document.BlockNodeSplitDoc, "s", map[string]any{"inversed": true}),
- Result: map[string]any{"inversed": true},
- },
- "Split doc not inversed": {
- Input: pmBlock(document.BlockNodeSplitDoc, "s", map[string]any{"inversed": false}),
- Result: nil,
- },
- "Metric attrs are opaque": {
- Input: pmBlock(document.BlockNodeMetricBlock, "m", map[string]any{"query": "up"}),
- Result: nil,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, summaryAttrs(c.Input))
- })
- }
-}
-
-func Test_blockRows(t *testing.T) {
- t.Parallel()
-
- cc := map[string]struct {
- Block document.Block
- Result []docSummaryEntry
- }{
- "Block without a uid yields nothing": {
- Block: pmBlock(document.BlockNodeParagraph, "", nil, pmText("x")),
- },
- "Block sits at depth zero with no parent": {
- Block: pmBlock(document.BlockNodeParagraph, "p", nil, pmText("hello")),
- Result: []docSummaryEntry{{UID: "p", Kind: "paragraph", Text: "hello"}},
- },
- "Nested entries follow the block": {
- Block: pmBlock(document.BlockNodeBulletList, "l", nil,
- pmBlock(document.BlockNodeListItem, "i", nil,
- pmBlock(document.BlockNodeParagraph, "", nil, pmText("one")),
- ),
- ),
- Result: []docSummaryEntry{
- {UID: "l", Kind: "bullet_list", Text: "one"},
- {UID: "i", Kind: "list_item", Text: "one", Depth: 1, ParentUID: "l"},
- },
- },
- // the document walk never lists a titled code block on its own,
- // but a write of one still has to hand its uid back.
- "Kind the document walk skips still gets its row": {
- Block: pmBlock(document.BlockNodeTitledCodeBlock, "t", map[string]any{"title": "Request"}, pmText("GET /")),
- Result: []docSummaryEntry{{UID: "t", Kind: "titled_code", Text: "GET /"}},
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, blockRows(c.Block))
- })
- }
-}
diff --git a/server/core/internal/assistant/tools/eino.go b/server/core/internal/assistant/tools/eino.go
index 0fa514cc..85a863e9 100644
--- a/server/core/internal/assistant/tools/eino.go
+++ b/server/core/internal/assistant/tools/eino.go
@@ -25,7 +25,7 @@ import (
//
// It is deliberately not called read_file: the assistant's vocabulary
// is documents and blocks, and a generic file tool sitting next to
-// read_block invites the model to reach for the wrong one.
+// get_document invites the model to reach for the wrong one.
const NameReadToolOutput Name = "read_tool_output"
// readToolOutputArgs is what read_tool_output is called with.
@@ -45,15 +45,13 @@ func (a readToolOutputArgs) Validate() error {
// readToolOutput hands the model back a result the reduction middleware
// moved out of the conversation.
-type readToolOutput struct {
- plainSummary
- plainTitle
-}
+type readToolOutput struct{}
// Info returns the tool's model-facing description.
func (readToolOutput) Info() Info {
return Info{
- Name: NameReadToolOutput,
+ Name: NameReadToolOutput,
+ Traits: Traits{Internal: true},
Description: "Retrieve the full output of an earlier tool call that was too large to keep in the conversation. " +
"Pass the path shown in the truncation notice.",
Properties: map[string]any{
@@ -63,22 +61,15 @@ func (readToolOutput) Info() Info {
}
}
-// Traits reports an internal read: the paths this takes are minted by
-// the reduction middleware during a chat turn, so a client holding none
-// of that state would be offered a tool it can never call.
-func (readToolOutput) Traits() Traits {
- return Traits{Internal: true}
-}
-
// Execute returns the stored output.
-func (readToolOutput) Execute(inp Input) (string, error) {
+func (readToolOutput) Execute(inp *input) (string, error) {
var in readToolOutputArgs
if err := inp.Decode(&in); err != nil {
return "", err
}
- return inp.ReadOffloaded(in.FilePath)
+ return inp.offload.Read(inp.Context(), in.FilePath)
}
// einoTool adapts one of this package's tools to the interface the
@@ -87,7 +78,7 @@ type einoTool struct {
// tl is the tool being adapted.
tl Tool
- // deps is the session wiring each call's Input is built from.
+ // deps is the session wiring each call's input is built from.
deps *Deps
// info is the tool's description, resolved once because it never
@@ -106,22 +97,15 @@ func (et *einoTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return et.info.toEino()
}
-// Run performs the call, handing the tool an Input built from this
-// call's context and arguments, and reports what the call changed
-// alongside what it produced.
-//
-// The documents come back even on failure: a tool that errors partway
-// may already have changed something, and a caller is better told than
-// left to assume otherwise.
-func (et *einoTool) Run(ctx context.Context, args json.RawMessage) (Result, error) {
- inp := et.input(ctx, string(args))
+// Run performs the call, handing the tool an input built from this
+// call's context and arguments. A failure names the tool.
+func (et *einoTool) Run(ctx context.Context, args json.RawMessage) (string, error) {
+ out, err := et.tl.Execute(et.input(ctx, string(args)))
+ if err != nil {
+ return "", fmt.Errorf("%s: %w", et.info.Name, err)
+ }
- out, err := et.tl.Execute(inp)
-
- return Result{
- Output: out,
- Documents: inp.touched,
- }, err
+ return out, nil
}
// InvokableRun performs the call for the agent framework, which hands
@@ -145,24 +129,50 @@ func (et *einoTool) InvokableRun(
argumentsInJSON string,
_ ...tool.Option,
) (string, error) {
- res, err := et.Run(ctx, json.RawMessage(argumentsInJSON))
+ out, err := et.Run(ctx, json.RawMessage(argumentsInJSON))
if err != nil {
//nolint:nilerr // the failure is the call's result here, not the turn's
return err.Error(), nil
}
- return res.Output, err
+ return out, nil
}
-// Title returns the status line shown while the tool runs.
-func (et *einoTool) Title(ctx context.Context, args json.RawMessage) (string, error) {
- return et.tl.Title(et.input(ctx, string(args)))
+// Title returns the status line shown while the tool runs: a write's
+// summary, a read's own title, or nothing for a read without one or
+// arguments that cannot be described.
+func (et *einoTool) Title(ctx context.Context, args json.RawMessage) string {
+ if et.info.Traits.Write {
+ sum, err := et.Summary(ctx, args)
+ if err != nil {
+ return ""
+ }
+
+ return sum.Summary
+ }
+
+ if t, ok := et.tl.(titler); ok {
+ return t.Title(et.input(ctx, string(args)))
+ }
+
+ return ""
}
// Summary describes the pending write for the user. It is only reached
-// for a tool the registry gated, which is only ever a write.
+// for a tool the registry gated, which is only ever a write, and every
+// write describes itself. A failure names the tool.
func (et *einoTool) Summary(ctx context.Context, args json.RawMessage) (ActionSummary, error) {
- return et.tl.Summary(et.input(ctx, string(args)))
+ s, ok := et.tl.(summarizer)
+ if !ok {
+ return ActionSummary{}, fmt.Errorf("%s: proposes no change to describe", et.info.Name)
+ }
+
+ sum, err := s.Summary(et.input(ctx, string(args)))
+ if err != nil {
+ return ActionSummary{}, fmt.Errorf("%s: %w", et.info.Name, err)
+ }
+
+ return sum, nil
}
// input assembles the per-call input the tool is handed.
diff --git a/server/core/internal/assistant/tools/eino_test.go b/server/core/internal/assistant/tools/eino_test.go
index 2d72b551..2dd365c0 100644
--- a/server/core/internal/assistant/tools/eino_test.go
+++ b/server/core/internal/assistant/tools/eino_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"os"
+ "strings"
"testing"
"github.com/oxynote/oxynote/server/core/internal/datasource"
@@ -48,6 +49,10 @@ func Test_Info_toEino(t *testing.T) {
data, err := json.Marshal(got)
require.NoError(t, err)
+ // every session sends the whole tool list, and an MCP client keeps
+ // it in context, so it stays under about 6k tokens.
+ assert.Less(t, len(data), 22000, "the tool list has grown past its budget")
+
// a description change is meant to be reviewed as a golden diff, so
// the file is rewritten on request rather than by hand.
if os.Getenv("UPDATE_GOLDEN") != "" {
@@ -89,28 +94,18 @@ func Test_einoTool_Info(t *testing.T) {
func Test_einoTool_Run(t *testing.T) {
t.Parallel()
- // error: the arguments never reach a tool that cannot read them
- res, err := newEinoTool(getDocument{}, testDeps(nil, nil, nil)).
+ // error: the arguments never reach a tool that cannot read them, and
+ // the failure names the tool.
+ _, err := newEinoTool(getDocument{}, testDeps(nil, nil, nil)).
Run(context.Background(), json.RawMessage(`{`))
require.Error(t, err)
- assert.Empty(t, res.Documents)
+ assert.True(t, strings.HasPrefix(err.Error(), "get_document: invalid input: "))
- // a read changes nothing, so it has nothing to report changing
- res, err = newEinoTool(listDocuments{}, testDeps(nil, nil, nil)).
+ // success: the tool's own output comes back as it is
+ out, err := newEinoTool(listDocuments{}, testDeps(nil, nil, nil)).
Run(context.Background(), json.RawMessage(`{}`))
require.NoError(t, err)
- assert.JSONEq(t, `{"documents":null}`, res.Output)
- assert.Empty(t, res.Documents)
-
- // a write reports the document it changed, taken from the edit it
- // actually applied rather than from its arguments
- res, err = newEinoTool(updateBlockText{}, testDeps(stubContentDB(nil), stubApplier(), nil)).
- Run(context.Background(), json.RawMessage(
- `{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a","text":"hi"}`,
- ))
- require.NoError(t, err)
- assert.JSONEq(t, `{"blocks":[{"uid":"a","kind":"paragraph","text":"hi","depth":0}]}`, res.Output)
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubMainBranchID}}, res.Documents)
+ assert.JSONEq(t, `{"documents":[]}`, out)
}
func Test_einoTool_InvokableRun(t *testing.T) {
@@ -120,7 +115,7 @@ func Test_einoTool_InvokableRun(t *testing.T) {
res, err := newEinoTool(listDocuments{}, testDeps(nil, nil, nil)).
InvokableRun(context.Background(), `{}`)
require.NoError(t, err)
- assert.JSONEq(t, `{"documents":null}`, res)
+ assert.JSONEq(t, `{"documents":[]}`, res)
// a failure comes back as the call's result rather than as an
// error: the framework would end the whole turn on an error, and
@@ -139,7 +134,7 @@ func Test_einoTool_InvokableRun(t *testing.T) {
args := `{"data_source_id":"` + _testDataSourceID.String() + `","query":"up)"}`
- res, err = newEinoTool(queryPrometheus{}, failing).InvokableRun(context.Background(), args)
+ res, err = newEinoTool(queryDataSource{}, failing).InvokableRun(context.Background(), args)
require.NoError(t, err)
assert.Contains(t, res, "parse error")
}
@@ -147,11 +142,20 @@ func Test_einoTool_InvokableRun(t *testing.T) {
func Test_einoTool_Title(t *testing.T) {
t.Parallel()
- et := newEinoTool(getDocument{}, testDeps(stubDocumentDB(), nil, nil))
+ ctx := context.Background()
+ d := testDeps(stubContentDB(nil), nil, nil)
- got, err := et.Title(context.Background(), json.RawMessage(`{`+targetArgs(_stubMainBranchID)+`}`))
- require.NoError(t, err)
- assert.Equal(t, "Reading Runbook", got)
+ // a read announces itself with its own title.
+ assert.Equal(t, "Reading Runbook", newEinoTool(getDocument{}, d).Title(ctx, json.RawMessage(`{`+targetArgs(_stubMainBranchID)+`}`)))
+
+ // a read without one runs silently.
+ assert.Empty(t, newEinoTool(listDocuments{}, d).Title(ctx, json.RawMessage(`{}`)))
+
+ // a write is announced by its summary, and by nothing when its
+ // arguments cannot be read.
+ write := newEinoTool(deleteBlock{}, d)
+ assert.Equal(t, "Delete a block in Runbook", write.Title(ctx, json.RawMessage(`{`+targetArgs(_stubMainBranchID)+`,"block_uid":"a"}`)))
+ assert.Empty(t, write.Title(ctx, json.RawMessage(`{`)))
}
func Test_einoTool_Summary(t *testing.T) {
@@ -167,16 +171,12 @@ func Test_einoTool_Summary(t *testing.T) {
// arguments the tool cannot read are refused rather than described:
// the same payload would fail on resume anyway.
- _, err = et.Summary(context.Background(), json.RawMessage(`{`))
+ _, err = et.Summary(context.Background(), json.RawMessage(`{"document_id":null}`))
+ assert.EqualError(t, err, "delete_document: document_id is required")
+
+ // a read proposes nothing, so it has nothing to describe.
+ _, err = newEinoTool(getDocument{}, testDeps(nil, nil, nil)).Summary(context.Background(), json.RawMessage(`{}`))
require.Error(t, err)
-
- // a read proposes nothing and is never gated, so the adapter reaches
- // its plainSummary and comes back with nothing to show.
- read := newEinoTool(getDocument{}, testDeps(nil, nil, nil))
-
- got, err = read.Summary(context.Background(), json.RawMessage(`{}`))
- require.NoError(t, err)
- assert.Equal(t, ActionSummary{}, got)
}
func Test_readToolOutputArgs_Validate(t *testing.T) {
@@ -192,30 +192,18 @@ func Test_readToolOutput_Info(t *testing.T) {
info := readToolOutput{}.Info()
+ // the paths it takes only exist inside a conversation, so it must
+ // never be offered to a client that has none.
+ assert.Equal(t, Traits{Internal: true}, info.Traits)
+
// the tool is named for tool output, not files: sitting next to
- // read_block, a generic read_file would invite the wrong call.
+ // get_document, a generic read_file would invite the wrong call.
assert.Equal(t, NameReadToolOutput, info.Name)
assert.NotEmpty(t, info.Description)
assert.Contains(t, info.Properties, "file_path")
assert.Equal(t, []string{"file_path"}, info.Required)
}
-func Test_readToolOutput_Traits(t *testing.T) {
- t.Parallel()
-
- // the paths it takes only exist inside a conversation, so it must
- // never be offered to a client that has none.
- assert.Equal(t, Traits{Internal: true}, readToolOutput{}.Traits())
-}
-
-func Test_readToolOutput_Title(t *testing.T) {
- t.Parallel()
-
- got, err := readToolOutput{}.Title(testInput(testDeps(nil, nil, nil), NameReadToolOutput, `{}`))
- require.NoError(t, err)
- assert.Empty(t, got)
-}
-
func Test_readToolOutput_Execute(t *testing.T) {
t.Parallel()
diff --git a/server/core/internal/assistant/tools/hook.go b/server/core/internal/assistant/tools/hook.go
index 4970340b..f522e7f1 100644
--- a/server/core/internal/assistant/tools/hook.go
+++ b/server/core/internal/assistant/tools/hook.go
@@ -25,25 +25,23 @@ func hookSettingsSchema() map[string]any {
"description": "The hook's type and its settings.",
"anyOf": []map[string]any{
{
- "title": string(hook.TypeScheduledReminder),
- "type": "object",
+ "type": "object",
"properties": map[string]any{
"type": map[string]any{"const": string(hook.TypeScheduledReminder)},
- "schedule": map[string]any{"type": "string", "description": "The RFC 3339 timestamp at which the reminder is due."},
+ "schedule": map[string]any{"type": "string", "description": "When the reminder is due, RFC 3339."},
},
"required": []string{"type", "schedule"},
"additionalProperties": false,
},
{
- "title": string(hook.TypeGithubTracking),
- "type": "object",
+ "type": "object",
"properties": map[string]any{
"type": map[string]any{"const": string(hook.TypeGithubTracking)},
- "repository": map[string]any{"type": "string", "description": "The repository to follow, by its name alone; the connected GitHub account is its owner."},
- "branch": map[string]any{"type": "string", "description": "The repository branch to follow."},
+ "repository": map[string]any{"type": "string", "description": "The repository name, owned by the connected GitHub account."},
+ "branch": map[string]any{"type": "string", "description": "The branch to follow."},
"paths": map[string]any{
"type": "array",
- "description": "The repository paths whose changes mark the document stale.",
+ "description": "The paths whose changes mark the document stale.",
"items": map[string]any{"type": "string"},
"minItems": 1,
},
@@ -52,21 +50,19 @@ func hookSettingsSchema() map[string]any {
"additionalProperties": false,
},
{
- "title": string(hook.TypeURLWatcher),
- "type": "object",
+ "type": "object",
"properties": map[string]any{
"type": map[string]any{"const": string(hook.TypeURLWatcher)},
- "url": map[string]any{"type": "string", "description": "The web address to watch for changes."},
+ "url": map[string]any{"type": "string", "description": "The address to watch."},
},
"required": []string{"type", "url"},
"additionalProperties": false,
},
{
- "title": string(hook.TypeContainerImageWatcher),
- "type": "object",
+ "type": "object",
"properties": map[string]any{
"type": map[string]any{"const": string(hook.TypeContainerImageWatcher)},
- "image": map[string]any{"type": "string", "description": "The container image reference to watch for new digests."},
+ "image": map[string]any{"type": "string", "description": "The image reference to watch for new digests."},
},
"required": []string{"type", "image"},
"additionalProperties": false,
@@ -105,13 +101,8 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
switch tp {
case hook.TypeScheduledReminder:
- var sr struct {
- processor.ScheduledReminder
-
- Type hook.Type `json:"type"`
- }
-
- if err := decodeSettings(tp, raw, &sr); err != nil {
+ sr, err := decodeSettings[processor.ScheduledReminder](tp, raw)
+ if err != nil {
return "", nil, err
}
@@ -122,15 +113,10 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
// a concrete date is what the editor calls a custom duration.
sr.Scale = processor.ScaleTypeLinear
sr.Duration = null.StringFrom("custom")
- v = sr.ScheduledReminder
+ v = sr
case hook.TypeGithubTracking:
- var gt struct {
- processor.GithubTracking
-
- Type hook.Type `json:"type"`
- }
-
- if err := decodeSettings(tp, raw, >); err != nil {
+ gt, err := decodeSettings[processor.GithubTracking](tp, raw)
+ if err != nil {
return "", nil, err
}
@@ -143,15 +129,10 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
return "", nil, errRequired("paths")
}
- v = gt.GithubTracking
+ v = gt
case hook.TypeURLWatcher:
- var uw struct {
- processor.URLWatcher
-
- Type hook.Type `json:"type"`
- }
-
- if err := decodeSettings(tp, raw, &uw); err != nil {
+ uw, err := decodeSettings[processor.URLWatcher](tp, raw)
+ if err != nil {
return "", nil, err
}
@@ -159,15 +140,10 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
return "", nil, errRequired("url")
}
- v = uw.URLWatcher
+ v = uw
case hook.TypeContainerImageWatcher:
- var ciw struct {
- processor.ContainerImageWatcher
-
- Type hook.Type `json:"type"`
- }
-
- if err := decodeSettings(tp, raw, &ciw); err != nil {
+ ciw, err := decodeSettings[processor.ContainerImageWatcher](tp, raw)
+ if err != nil {
return "", nil, err
}
@@ -175,7 +151,7 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
return "", nil, errRequired("image")
}
- v = ciw.ContainerImageWatcher
+ v = ciw
}
out, err := json.Marshal(v)
@@ -187,26 +163,29 @@ func decodeHookSettings(raw json.RawMessage) (hook.Type, processor.Settings, err
}
// decodeSettings unmarshals the settings into the processor struct of
-// the given type, refusing a field the struct does not have. An absent
-// payload is an empty one, so the type's own check reports what is
-// missing.
-func decodeSettings(tp hook.Type, raw json.RawMessage, dst any) error {
- if len(bytes.TrimSpace(raw)) == 0 {
- raw = json.RawMessage("{}")
+// the given type, refusing a field the struct does not have. The type
+// field sits beside the settings, so it is not refused.
+func decodeSettings[T any](tp hook.Type, raw json.RawMessage) (T, error) {
+ var dst struct {
+ // Settings is the processor struct the settings decode into.
+ Settings T `json:",embed"` //nolint:revive // embed is the json/v2 option revive does not know yet
+
+ // Type is the hook type the settings belong to.
+ Type hook.Type `json:"type"`
}
- err := jsonv2.Unmarshal(raw, dst, jsonv2.RejectUnknownMembers(true))
+ err := jsonv2.Unmarshal(raw, &dst, jsonv2.RejectUnknownMembers(true))
if err == nil {
- return nil
+ return dst.Settings, nil
}
var serr *jsonv2.SemanticError
if errors.As(err, &serr) && errors.Is(serr.Err, jsonv2.ErrUnknownName) {
- return fmt.Errorf("%s is not a %s setting", serr.JSONPointer.LastToken(), tp)
+ return dst.Settings, fmt.Errorf("%s is not a %s setting", serr.JSONPointer.LastToken(), tp)
}
- return fmt.Errorf("invalid settings: %w", err)
+ return dst.Settings, fmt.Errorf("invalid settings: %w", err)
}
// listHooksArgs is what list_hooks is called with.
@@ -214,33 +193,24 @@ type listHooksArgs struct {
docTarget
}
-// Validate checks the arguments name a branch.
-func (a listHooksArgs) Validate() error {
- return a.validate()
-}
-
// listHooks returns every hook on one branch of a document.
-type listHooks struct {
- plainSummary
- plainTraits
- plainTitle
-}
+type listHooks struct{}
// Info returns the tool's model-facing description.
func (listHooks) Info() Info {
return Info{
Name: NameListHooks,
- Description: "List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.",
+ Description: "List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, status, created_at, updated_at}]. A hook watches something outside the document and lowers its score from 100 as that thing changes or a date approaches. block_uid is the block it is anchored to, or null for the whole document.",
Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "branch_id": map[string]any{"type": "string", "description": "The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists."},
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
},
Required: []string{"document_id", "branch_id"},
}
}
// Execute lists the branch's hooks.
-func (listHooks) Execute(inp Input) (string, error) {
+func (listHooks) Execute(inp *input) (string, error) {
var in listHooksArgs
if err := inp.Decode(&in); err != nil {
@@ -249,7 +219,7 @@ func (listHooks) Execute(inp Input) (string, error) {
hooks, err := inp.FetchHooks(in.DocumentID, in.BranchID)
if err != nil {
- return "", fmt.Errorf("list_hooks: %w", err)
+ return "", err
}
out := hookListResult{Hooks: make([]hookRow, 0, len(hooks))}
@@ -331,7 +301,7 @@ type createHookArgs struct {
// Validate checks the arguments name a branch and carry settings naming
// a hook type, with exactly that type's fields.
func (a createHookArgs) Validate() error {
- if err := a.validate(); err != nil {
+ if err := a.docTarget.Validate(); err != nil {
return err
}
@@ -347,38 +317,18 @@ type createHook struct{}
func (createHook) Info() Info {
return Info{
Name: NameCreateHook,
- Description: "Add a freshness hook to one branch of a document and return it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.",
+ Traits: Traits{Write: true},
+ Description: "Add a freshness hook to one branch of a document and return it as list_hooks would. github-tracking and url-watcher need the deployment's GitHub App and changedetection.io integrations, and are refused without them. A new hook starts at score 100.",
Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "branch_id": map[string]any{"type": "string", "description": "The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists."},
- "block_uid": map[string]any{"type": "string", "description": "Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole."},
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
+ "block_uid": map[string]any{"type": "string", "description": "Optional. The id of a block on the branch to anchor the hook to; omit it for the whole document."},
"settings": hookSettingsSchema(),
},
Required: []string{"document_id", "branch_id", "settings"},
}
}
-// Traits reports a write.
-func (createHook) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which document gets the hook.
-func (createHook) Title(inp DescribeInput) (string, error) {
- var in createHookArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameCreateHook, err)
- }
-
- return "Adding a hook to " + doc.Title(), nil
-}
-
// Summary names the hook type and where it goes.
func (createHook) Summary(inp DescribeInput) (ActionSummary, error) {
var in createHookArgs
@@ -389,13 +339,13 @@ func (createHook) Summary(inp DescribeInput) (ActionSummary, error) {
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameCreateHook, err)
+ return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
}
// Validate already accepted the settings, so this cannot fail.
tp, _, err := decodeHookSettings(in.Settings)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: %w", NameCreateHook, err)
+ return ActionSummary{}, err
}
return ActionSummary{
@@ -407,7 +357,7 @@ func (createHook) Summary(inp DescribeInput) (ActionSummary, error) {
}
// Execute creates the hook.
-func (createHook) Execute(inp Input) (string, error) {
+func (createHook) Execute(inp *input) (string, error) {
var in createHookArgs
if err := inp.Decode(&in); err != nil {
@@ -417,12 +367,12 @@ func (createHook) Execute(inp Input) (string, error) {
// Validate already accepted the settings, so this cannot fail.
tp, settings, err := decodeHookSettings(in.Settings)
if err != nil {
- return "", fmt.Errorf("create_hook: %w", err)
+ return "", err
}
hk, err := inp.CreateHook(in.DocumentID, in.BranchID, in.BlockUID, tp, settings)
if err != nil {
- return "", fmt.Errorf("create_hook: %w", err)
+ return "", err
}
return result(newHookRow(hk))
@@ -433,83 +383,71 @@ type updateHookArgs struct {
hookRefArgs
// Settings is the hook's type and its new settings in the shape
- // that type takes.
+ // that type takes; absent keeps the settings and only resets the
+ // hook.
Settings json.RawMessage `json:"settings"`
}
-// Validate checks the arguments name a hook and carry settings naming a
-// hook type, with exactly that type's fields. That the type is the
-// hook's own is checked once it is fetched.
+// Validate checks the arguments name a hook and, when they carry
+// settings, that those name a hook type with exactly that type's
+// fields. That the type is the hook's own is checked once it is
+// fetched.
func (a updateHookArgs) Validate() error {
if err := a.hookRefArgs.Validate(); err != nil {
return err
}
+ if !a.replacesSettings() {
+ return nil
+ }
+
_, _, err := decodeHookSettings(a.Settings)
return err
}
-// decodeUpdateHookSettings reads the settings of an update_hook call
-// for the hook it names, refusing settings of another type: a hook
-// keeps its type for life.
-func decodeUpdateHookSettings(hk *hook.Hook, raw json.RawMessage) (processor.Settings, error) {
- tp, settings, err := decodeHookSettings(raw)
+// replacesSettings reports whether the call carries new settings, as
+// opposed to only resetting the hook.
+func (a updateHookArgs) replacesSettings() bool {
+ trimmed := bytes.TrimSpace(a.Settings)
+
+ return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null"))
+}
+
+// settingsFor reads the call's settings for the hook it names, refusing
+// settings of another type: a hook keeps its type for life.
+func (a updateHookArgs) settingsFor(hk *hook.Hook) (processor.Settings, error) {
+ tp, settings, err := decodeHookSettings(a.Settings)
if err != nil {
return nil, err
}
if tp != hk.Type {
- return nil, errHookTypeMismatch(hk.Type, tp)
+ return nil, fmt.Errorf("the hook is a %s, not a %s; to change the type, delete it and create another", hk.Type, tp)
}
return settings, nil
}
-// errHookTypeMismatch reports settings of one type sent to a hook of
-// another.
-func errHookTypeMismatch(have, got hook.Type) error {
- return fmt.Errorf("the hook is a %s, not a %s; to change the type, delete it and create another", have, got)
-}
-
-// updateHook replaces a hook's settings.
+// updateHook resets a hook's score and state, replacing its settings
+// when new ones are given.
type updateHook struct{}
// Info returns the tool's model-facing description.
func (updateHook) Info() Info {
return Info{
Name: NameUpdateHook,
- Description: "Replace the settings of a hook and reset its score and state, returning it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. The hook keeps its type, so settings.type is the type list_hooks reports for it; to change the type, delete the hook and create another.",
+ Traits: Traits{Write: true},
+ Description: "Reset a hook's score to 100 and its state to a fresh hook's, and return it as list_hooks would. Without settings the hook keeps them: use that once the document is brought up to date with what the hook flagged. With settings they replace the old ones; the hook keeps its type, so to change it delete the hook and create another.",
Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "hook_id": map[string]any{"type": "string", "description": "The hook id, as list_hooks reports it."},
- "settings": hookSettingsSchema(),
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "hook_id": map[string]any{"type": "string", "description": _hookIDDescription},
+ "settings": map[string]any{"type": "object", "description": "Optional. New settings, shaped as create_hook's, with the hook's own type."},
},
- Required: []string{"document_id", "hook_id", "settings"},
+ Required: []string{"document_id", "hook_id"},
}
}
-// Traits reports a write.
-func (updateHook) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which hook is being updated.
-func (updateHook) Title(inp DescribeInput) (string, error) {
- var in updateHookArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchDocument(in.DocumentID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameUpdateHook, err)
- }
-
- return "Updating a hook on " + doc.DocumentName, nil
-}
-
// Summary names the hook and checks the settings fit its type, so the
// user is not asked to approve a call that cannot run.
func (updateHook) Summary(inp DescribeInput) (ActionSummary, error) {
@@ -519,25 +457,32 @@ func (updateHook) Summary(inp DescribeInput) (ActionSummary, error) {
return ActionSummary{}, err
}
- doc, hk, err := describeHook(inp, NameUpdateHook, in.hookRefArgs)
+ doc, hk, err := in.fetchHookAndBranch(inp)
if err != nil {
return ActionSummary{}, err
}
- if _, err := decodeUpdateHookSettings(hk, in.Settings); err != nil {
- return ActionSummary{}, fmt.Errorf("%s: %w", NameUpdateHook, err)
+ summary := "Reset the " + hookLabel(hk.Type, hk.BlockID, doc)
+
+ if in.replacesSettings() {
+ if _, err := in.settingsFor(hk); err != nil {
+ return ActionSummary{}, err
+ }
+
+ summary = "Update the " + hookLabel(hk.Type, hk.BlockID, doc)
}
return ActionSummary{
Tool: NameUpdateHook,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
- Summary: "Update the " + hookLabel(hk.Type, hk.BlockID, doc),
+ Summary: summary,
}, nil
}
-// Execute replaces the settings and resets the hook.
-func (updateHook) Execute(inp Input) (string, error) {
+// Execute resets the hook, replacing its settings when new ones were
+// given.
+func (updateHook) Execute(inp *input) (string, error) {
var in updateHookArgs
if err := inp.Decode(&in); err != nil {
@@ -546,23 +491,31 @@ func (updateHook) Execute(inp Input) (string, error) {
hk, err := inp.FetchHook(in.DocumentID, in.HookID)
if err != nil {
- return "", fmt.Errorf("update_hook: %w", err)
+ return "", err
}
- settings, err := decodeUpdateHookSettings(hk, in.Settings)
+ if !in.replacesSettings() {
+ if rerr := inp.ResetHook(hk); rerr != nil {
+ return "", rerr
+ }
+
+ return result(newHookRow(hk))
+ }
+
+ settings, err := in.settingsFor(hk)
if err != nil {
- return "", fmt.Errorf("update_hook: %w", err)
+ return "", err
}
if err := inp.UpdateHook(hk, settings); err != nil {
- return "", fmt.Errorf("update_hook: %w", err)
+ return "", err
}
return result(newHookRow(hk))
}
-// hookRefArgs is what reset_hook and delete_hook are called with: a
-// document and one of its hooks.
+// hookRefArgs is a document and one of its hooks, which is what
+// delete_hook is called with and update_hook starts from.
type hookRefArgs struct {
// DocumentID names the document the hook is on.
DocumentID xid.ID `json:"document_id"`
@@ -584,91 +537,6 @@ func (a hookRefArgs) Validate() error {
return nil
}
-// hookRefProps builds the argument schema reset_hook and delete_hook
-// share.
-func hookRefProps() map[string]any {
- return map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "hook_id": map[string]any{"type": "string", "description": "The hook id, as list_hooks reports it."},
- }
-}
-
-// resetHook restores a hook's score and state without touching its
-// settings.
-type resetHook struct{}
-
-// Info returns the tool's model-facing description.
-func (resetHook) Info() Info {
- return Info{
- Name: NameResetHook,
- Description: "Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.",
- Properties: hookRefProps(),
- Required: []string{"document_id", "hook_id"},
- }
-}
-
-// Traits reports a write.
-func (resetHook) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which hook is being reset.
-func (resetHook) Title(inp DescribeInput) (string, error) {
- var in hookRefArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchDocument(in.DocumentID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameResetHook, err)
- }
-
- return "Resetting a hook on " + doc.DocumentName, nil
-}
-
-// Summary names the hook being reset.
-func (resetHook) Summary(inp DescribeInput) (ActionSummary, error) {
- var in hookRefArgs
-
- if err := inp.Decode(&in); err != nil {
- return ActionSummary{}, err
- }
-
- doc, hk, err := describeHook(inp, NameResetHook, in)
- if err != nil {
- return ActionSummary{}, err
- }
-
- return ActionSummary{
- Tool: NameResetHook,
- DocumentID: doc.ID,
- DocumentName: doc.DocumentName,
- Summary: "Reset the " + hookLabel(hk.Type, hk.BlockID, doc),
- }, nil
-}
-
-// Execute resets the hook.
-func (resetHook) Execute(inp Input) (string, error) {
- var in hookRefArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- hk, err := inp.FetchHook(in.DocumentID, in.HookID)
- if err != nil {
- return "", fmt.Errorf("reset_hook: %w", err)
- }
-
- if err := inp.ResetHook(hk); err != nil {
- return "", fmt.Errorf("reset_hook: %w", err)
- }
-
- return result(newHookRow(hk))
-}
-
// deleteHook removes a hook from its document.
type deleteHook struct{}
@@ -676,34 +544,16 @@ type deleteHook struct{}
func (deleteHook) Info() Info {
return Info{
Name: NameDeleteHook,
- Description: "Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.",
- Properties: hookRefProps(),
- Required: []string{"document_id", "hook_id"},
+ Traits: Traits{Write: true, Destructive: true},
+ Description: "Delete a hook, tearing down whatever it watches outside the document. It cannot be restored; update_hook without settings clears what it flagged instead. Returns {hook_id, deleted}.",
+ Properties: map[string]any{
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "hook_id": map[string]any{"type": "string", "description": _hookIDDescription},
+ },
+ Required: []string{"document_id", "hook_id"},
}
}
-// Traits reports a destructive write, which stays outside any "approve
-// all" answer.
-func (deleteHook) Traits() Traits {
- return Traits{Write: true, Destructive: true}
-}
-
-// Title announces which hook is being deleted.
-func (deleteHook) Title(inp DescribeInput) (string, error) {
- var in hookRefArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchDocument(in.DocumentID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameDeleteHook, err)
- }
-
- return "Deleting a hook on " + doc.DocumentName, nil
-}
-
// Summary names the hook being deleted.
func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) {
var in hookRefArgs
@@ -712,7 +562,7 @@ func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) {
return ActionSummary{}, err
}
- doc, hk, err := describeHook(inp, NameDeleteHook, in)
+ doc, hk, err := in.fetchHookAndBranch(inp)
if err != nil {
return ActionSummary{}, err
}
@@ -726,7 +576,7 @@ func (deleteHook) Summary(inp DescribeInput) (ActionSummary, error) {
}
// Execute deletes the hook.
-func (deleteHook) Execute(inp Input) (string, error) {
+func (deleteHook) Execute(inp *input) (string, error) {
var in hookRefArgs
if err := inp.Decode(&in); err != nil {
@@ -735,11 +585,11 @@ func (deleteHook) Execute(inp Input) (string, error) {
hk, err := inp.FetchHook(in.DocumentID, in.HookID)
if err != nil {
- return "", fmt.Errorf("delete_hook: %w", err)
+ return "", err
}
if err := inp.DeleteHook(hk); err != nil {
- return "", fmt.Errorf("delete_hook: %w", err)
+ return "", err
}
return result(deletedHookResult{HookID: hk.ID, Deleted: true})
@@ -755,25 +605,26 @@ type deletedHookResult struct {
Deleted bool `json:"deleted"`
}
-// describeHook resolves the hook a description names and the document
-// on the branch the hook is on, so the label can name that branch. A
-// hook whose branch is gone is named on the document's default one.
-func describeHook(inp DescribeInput, name Name, ref hookRefArgs) (*document.Document, *hook.Hook, error) {
- hk, err := inp.FetchHook(ref.DocumentID, ref.HookID)
+// fetchHookAndBranch fetches the hook the arguments name and the
+// document on the branch the hook is on, so a label can name that
+// branch. A hook whose branch is gone is named on the document's default
+// one.
+func (a hookRefArgs) fetchHookAndBranch(inp DescribeInput) (*document.Document, *hook.Hook, error) {
+ hk, err := inp.FetchHook(a.DocumentID, a.HookID)
if err != nil {
- return nil, nil, fmt.Errorf("%s: fetch hook: %w", name, err)
+ return nil, nil, fmt.Errorf("fetch hook: %w", err)
}
var doc *document.Document
if hk.BranchID.Valid {
- doc, err = inp.FetchBranch(ref.DocumentID, hk.BranchID.V)
+ doc, err = inp.FetchBranch(a.DocumentID, hk.BranchID.V)
} else {
- doc, err = inp.FetchDocument(ref.DocumentID)
+ doc, err = inp.FetchDocument(a.DocumentID)
}
if err != nil {
- return nil, nil, fmt.Errorf("%s: fetch document: %w", name, err)
+ return nil, nil, fmt.Errorf("fetch document: %w", err)
}
return doc, hk, nil
diff --git a/server/core/internal/assistant/tools/hook_test.go b/server/core/internal/assistant/tools/hook_test.go
index e5ceeed6..e06788af 100644
--- a/server/core/internal/assistant/tools/hook_test.go
+++ b/server/core/internal/assistant/tools/hook_test.go
@@ -3,6 +3,7 @@ package tools
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"testing"
"time"
@@ -121,7 +122,6 @@ func Test_hookSettingsSchema(t *testing.T) {
tp := want[i]
require.NoError(t, tp.Validate())
- assert.Equal(t, string(tp), v["title"])
assert.Equal(t, false, v["additionalProperties"])
props, pok := v["properties"].(map[string]any)
@@ -254,36 +254,47 @@ func Test_decodeHookSettings(t *testing.T) {
func Test_decodeSettings(t *testing.T) {
t.Parallel()
- var uw processor.URLWatcher
-
- // an absent payload decodes as an empty one.
- require.NoError(t, decodeSettings(hook.TypeURLWatcher, nil, &uw))
- assert.Empty(t, uw.URL)
-
- require.NoError(t, decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{"url":"https://example.com"}`), &uw))
- assert.Equal(t, "https://example.com", uw.URL)
-
- err := decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{"colour":"red"}`), &uw)
- assert.Equal(t, fmt.Errorf("%s is not a %s setting", "colour", hook.TypeURLWatcher), err)
-
- // a value of the wrong shape is reported with its path.
- err = decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{"url":1}`), &uw)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "invalid settings")
-
- require.Error(t, decodeSettings(hook.TypeURLWatcher, json.RawMessage(`{`), &uw))
-}
-
-func Test_listHooksArgs_Validate(t *testing.T) {
- t.Parallel()
-
- assertValidate(t,
- listHooksArgs{docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}},
- map[string]Args{
- "document_id": listHooksArgs{docTarget{BranchID: _stubMainBranchID}},
- "branch_id": listHooksArgs{docTarget{DocumentID: _testDocID}},
+ cc := map[string]struct {
+ Raw string
+ Result processor.URLWatcher
+ Err error
+ }{
+ "Successful decode": {
+ Raw: `{"url":"https://example.com"}`,
+ Result: processor.URLWatcher{URL: "https://example.com"},
},
- )
+ "Type beside the settings": {
+ Raw: `{"type":"url-watcher","url":"https://example.com"}`,
+ Result: processor.URLWatcher{URL: "https://example.com"},
+ },
+ "Unknown field": {
+ Raw: `{"colour":"red"}`,
+ Err: fmt.Errorf("%s is not a %s setting", "colour", hook.TypeURLWatcher),
+ },
+ "Value of the wrong shape": {
+ Raw: `{"url":1}`,
+ Err: assert.AnError,
+ },
+ "Malformed payload": {
+ Raw: `{`,
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := decodeSettings[processor.URLWatcher](hook.TypeURLWatcher, json.RawMessage(c.Raw))
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ assert.Equal(t, c.Result, got)
+ })
+ }
}
func Test_listHooks_Info(t *testing.T) {
@@ -291,26 +302,14 @@ func Test_listHooks_Info(t *testing.T) {
info := listHooks{}.Info()
+ assert.Equal(t, Traits{}, info.Traits)
+
assert.Equal(t, NameListHooks, info.Name)
assert.NotEmpty(t, info.Description)
assert.Equal(t, []string{"document_id", "branch_id"}, info.Required)
assert.Contains(t, info.Properties, "branch_id")
}
-func Test_listHooks_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{}, listHooks{}.Traits())
-}
-
-func Test_listHooks_Title(t *testing.T) {
- t.Parallel()
-
- got, err := listHooks{}.Title(testInput(testDeps(nil, nil, nil), NameListHooks, `{}`))
- require.NoError(t, err)
- assert.Empty(t, got)
-}
-
func Test_listHooks_Execute(t *testing.T) {
t.Parallel()
@@ -429,6 +428,8 @@ func Test_createHook_Info(t *testing.T) {
info := createHook{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameCreateHook, info.Name)
assert.NotEmpty(t, info.Description)
assert.Equal(t, []string{"document_id", "branch_id", "settings"}, info.Required)
@@ -446,32 +447,6 @@ func Test_createHook_Info(t *testing.T) {
assert.Contains(t, info.Description, "integrations")
}
-func Test_createHook_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, createHook{}.Traits())
-}
-
-func Test_createHook_Title(t *testing.T) {
- t.Parallel()
-
- scheduled := `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}`
-
- got, err := createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubMainBranchID)+scheduled+`}`))
- require.NoError(t, err)
- assert.Equal(t, "Adding a hook to Runbook", got)
-
- got, err = createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`+targetArgs(_stubBranchID)+scheduled+`}`))
- require.NoError(t, err)
- assert.Equal(t, "Adding a hook to Runbook on branch draft", got)
-
- _, err = createHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameCreateHook, requiredArgs(t, NameCreateHook)))
- require.Error(t, err)
-
- _, err = createHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameCreateHook, `{`))
- require.Error(t, err)
-}
-
func Test_createHook_Summary(t *testing.T) {
t.Parallel()
@@ -531,7 +506,7 @@ func Test_createHook_Execute(t *testing.T) {
"Another type's field is refused": {
DB: stubHookDB(),
Args: `{` + targetArgs(_stubBranchID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `","url":"https://example.com"}}`,
- Err: fmt.Errorf("%s: %w", NameCreateHook, fmt.Errorf("%s is not a %s setting", "url", hook.TypeScheduledReminder)),
+ Err: fmt.Errorf("%s is not a %s setting", "url", hook.TypeScheduledReminder),
},
"Unknown branch": {
DB: stubHookDB(),
@@ -542,17 +517,17 @@ func Test_createHook_Execute(t *testing.T) {
DB: stubHookDB(),
Man: blockless,
Args: `{` + targetArgs(_stubBranchID) + `,` + scheduled + `,"block_uid":"nope"}`,
- Err: fmt.Errorf("create_hook: %w", fmt.Errorf("block %s: %w", "nope", errUnknownBlock)),
+ Err: fmt.Errorf("block %s: %w", "nope", errUnknownBlock),
},
"GitHub tracking without the app": {
DB: stubHookDB(),
Args: `{` + targetArgs(_stubBranchID) + `,"settings":{"type":"github-tracking","repository":"r","branch":"main","paths":["a"]}}`,
- Err: fmt.Errorf("create_hook: %w", fmt.Errorf("%s: %w", hook.TypeGithubTracking, github.ErrNotConfigured)),
+ Err: fmt.Errorf("%s: %w", hook.TypeGithubTracking, github.ErrNotConfigured),
},
"URL watcher without changedetection": {
DB: stubHookDB(),
Args: `{` + targetArgs(_stubBranchID) + `,"settings":{"type":"url-watcher","url":"https://example.com"}}`,
- Err: fmt.Errorf("create_hook: %w", fmt.Errorf("%s: %w", hook.TypeURLWatcher, webchange.ErrNotConfigured)),
+ Err: fmt.Errorf("%s: %w", hook.TypeURLWatcher, webchange.ErrNotConfigured),
},
"Error returned by hookMan.CreateHook": {
DB: stubHookDB(),
@@ -592,7 +567,7 @@ func Test_createHook_Execute(t *testing.T) {
assert.Equal(t, _stubBranchID, ff[0].Ci.BranchID)
assert.Equal(t, "org", ff[0].OrganizationID)
assert.Equal(t, c.BlockUID, ff[0].Ci.BlockID)
- assert.Equal(t, inp.UserID(), ff[0].UpdatedBy)
+ assert.Equal(t, inp.userID, ff[0].UpdatedBy)
row := decodeHookRow(t, res)
assert.False(t, row.ID.IsNil())
@@ -601,8 +576,6 @@ func Test_createHook_Execute(t *testing.T) {
assert.JSONEq(t, `{"scale":"linear","duration":"custom","schedule":"`+_stubSchedule+`"}`, string(row.Settings))
assert.Equal(t, "100", row.Score.String())
assert.Contains(t, string(row.State), "startedAt")
-
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
})
}
}
@@ -611,18 +584,67 @@ func Test_updateHookArgs_Validate(t *testing.T) {
t.Parallel()
settings := json.RawMessage(`{"type":"url-watcher","url":"https://example.com"}`)
+ ref := hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}
// the settings are checked against the hook's own type later, so any
- // type's complete settings pass here.
+ // type's complete settings pass here, and none at all is a reset.
assertValidate(t,
- updateHookArgs{DocumentID: _testDocID, HookID: _testHookID, Settings: settings},
+ updateHookArgs{hookRefArgs: ref, Settings: settings},
map[string]Args{
- "document_id": updateHookArgs{hookRefArgs: hookRefArgs{HookID: _testHookID}, Settings: settings},
- "hook_id": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID}, Settings: settings},
- "settings.type": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}},
- "url": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID, HookID: _testHookID}, Settings: json.RawMessage(`{"type":"url-watcher"}`)},
+ "document_id": updateHookArgs{hookRefArgs: hookRefArgs{HookID: _testHookID}, Settings: settings},
+ "hook_id": updateHookArgs{hookRefArgs: hookRefArgs{DocumentID: _testDocID}, Settings: settings},
+ "url": updateHookArgs{hookRefArgs: ref, Settings: json.RawMessage(`{"type":"url-watcher"}`)},
},
)
+
+ require.NoError(t, updateHookArgs{hookRefArgs: ref}.Validate())
+ require.NoError(t, updateHookArgs{hookRefArgs: ref, Settings: json.RawMessage(` null `)}.Validate())
+}
+
+func Test_updateHookArgs_settingsFor(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Settings string
+ Result string
+ Err error
+ }{
+ "Invalid settings": {
+ Settings: `{}`,
+ Err: errRequired("settings.type"),
+ },
+ "Another type's settings": {
+ Settings: `{"type":"url-watcher","url":"https://example.com"}`,
+ Err: errors.New("the hook is a scheduled-reminder, not a url-watcher; to change the type, delete it and create another"),
+ },
+ "Settings of the hook's type": {
+ Settings: `{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}`,
+ Result: `{"scale":"linear","duration":"custom","schedule":"` + _stubSchedule + `"}`,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := updateHookArgs{Settings: json.RawMessage(c.Settings)}.settingsFor(stubHook())
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ assert.JSONEq(t, c.Result, string(got))
+ })
+ }
+}
+
+func Test_updateHookArgs_replacesSettings(t *testing.T) {
+ t.Parallel()
+
+ assert.False(t, updateHookArgs{}.replacesSettings())
+ assert.False(t, updateHookArgs{Settings: json.RawMessage("null")}.replacesSettings())
+ assert.True(t, updateHookArgs{Settings: json.RawMessage(`{"type":"url-watcher"}`)}.replacesSettings())
}
func Test_updateHook_Info(t *testing.T) {
@@ -630,120 +652,148 @@ func Test_updateHook_Info(t *testing.T) {
info := updateHook{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameUpdateHook, info.Name)
assert.NotEmpty(t, info.Description)
- assert.Equal(t, []string{"document_id", "hook_id", "settings"}, info.Required)
+ assert.Equal(t, []string{"document_id", "hook_id"}, info.Required)
for _, key := range []string{"document_id", "hook_id", "settings"} {
assert.Contains(t, info.Properties, key)
}
- assert.Equal(t, hookSettingsSchema(), info.Properties["settings"])
- assert.NotContains(t, info.Properties, "type")
-
// a hook keeps its type, and the description says how to change it.
assert.Contains(t, info.Description, "delete the hook and create another")
}
-func Test_updateHook_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, updateHook{}.Traits())
-}
-
-func Test_updateHook_Title(t *testing.T) {
- t.Parallel()
-
- got, err := updateHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"type":"scheduled-reminder","schedule":"`+_stubSchedule+`"}}`))
- require.NoError(t, err)
- assert.Equal(t, "Updating a hook on Runbook", got)
-
- _, err = updateHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUpdateHook, requiredArgs(t, NameUpdateHook)))
- require.Error(t, err)
-
- _, err = updateHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`))
- require.Error(t, err)
-}
-
func Test_updateHook_Summary(t *testing.T) {
t.Parallel()
- got, err := updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"type":"scheduled-reminder","schedule":"`+_stubSchedule+`"}}`))
- require.NoError(t, err)
- assert.Equal(t, ActionSummary{
- Tool: NameUpdateHook,
- DocumentID: _testDocID,
- DocumentName: "Runbook",
- Summary: "Update the Scheduled Reminder hook on Runbook on branch draft",
- }, got)
+ cc := map[string]struct {
+ Args string
+ Result string
+ Err error
+ }{
+ "New settings": {
+ Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}}`,
+ Result: "Update the Scheduled Reminder hook on Runbook on branch draft",
+ },
+ "A reset": {
+ Args: `{` + hookArgs(_testHookID) + `}`,
+ Result: "Reset the Scheduled Reminder hook on Runbook on branch draft",
+ },
+ // settings of another type are refused here, so the user is not
+ // asked to approve a call that cannot run.
+ "Another type's settings": {
+ Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"url-watcher","url":"https://example.com"}}`,
+ Err: errors.New("the hook is a scheduled-reminder, not a url-watcher; to change the type, delete it and create another"),
+ },
+ "Unknown hook": {
+ Args: `{` + hookArgs(_unknownHookID) + `}`,
+ Err: assert.AnError,
+ },
+ "Malformed arguments": {Args: `{`, Err: assert.AnError},
+ }
- // settings of another type are refused here, so the user is not
- // asked to approve a call that cannot run.
- _, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_testHookID)+`,"settings":{"type":"url-watcher","url":"https://example.com"}}`))
- require.Error(t, err)
- assert.Equal(t, fmt.Errorf("%s: %w", NameUpdateHook, errHookTypeMismatch(hook.TypeScheduledReminder, hook.TypeURLWatcher)), err)
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
- _, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`+hookArgs(_unknownHookID)+`,"settings":{"type":"scheduled-reminder","schedule":"`+_stubSchedule+`"}}`))
- require.Error(t, err)
+ got, err := updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, c.Args))
+ testutil.AssertEqualError(t, c.Err, err)
- _, err = updateHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameUpdateHook, `{`))
- require.Error(t, err)
+ if err != nil {
+ return
+ }
+
+ assert.Equal(t, ActionSummary{
+ Tool: NameUpdateHook,
+ DocumentID: _testDocID,
+ DocumentName: "Runbook",
+ Summary: c.Result,
+ }, got)
+ })
+ }
}
func Test_updateHook_Execute(t *testing.T) {
t.Parallel()
- failing := stubHookManager()
- failing.UpdateHookFunc = func(context.Context, xid.ID, xid.ID, string, hook.UpdateInput, string) (*hook.Hook, error) {
+ failingUpdate := stubHookManager()
+ failingUpdate.UpdateHookFunc = func(context.Context, xid.ID, xid.ID, string, hook.UpdateInput, string) (*hook.Hook, error) {
+ return nil, assert.AnError
+ }
+
+ failingReset := stubHookManager()
+ failingReset.ResetHookFunc = func(context.Context, xid.ID, xid.ID, string) (*hook.Hook, error) {
return nil, assert.AnError
}
later := time.Date(2031, 6, 1, 12, 0, 0, 0, time.UTC)
cc := map[string]struct {
- DB *DBMock
- Man *HookManagerMock
- Args string
- Err error
+ DB *DBMock
+ Man *HookManagerMock
+ Args string
+ Updates int
+ Resets int
+ Settings string
+ Err error
}{
"Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError},
"Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError},
"Unknown hook": {
DB: stubHookDB(),
Args: `{` + hookArgs(_unknownHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `"}}`,
- Err: fmt.Errorf("update_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
+ Err: fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook),
},
- "Settings are required": {
+ "Settings without a type": {
DB: stubHookDB(),
- Args: `{` + hookArgs(_testHookID) + `}`,
- Err: fmt.Errorf("%s: %w", NameUpdateHook, errRequired("settings"+"."+"type")),
+ Args: `{` + hookArgs(_testHookID) + `,"settings":{}}`,
+ Err: errRequired("settings" + "." + "type"),
},
"Schedule is required for a scheduled reminder": {
DB: stubHookDB(),
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder"}}`,
- Err: fmt.Errorf("%s: %w", NameUpdateHook, errRequired("schedule")),
+ Err: errRequired("schedule"),
},
"Another type's field is refused": {
DB: stubHookDB(),
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + _stubSchedule + `","image":"nginx:1"}}`,
- Err: fmt.Errorf("%s: %w", NameUpdateHook, fmt.Errorf("%s is not a %s setting", "image", hook.TypeScheduledReminder)),
+ Err: fmt.Errorf("%s is not a %s setting", "image", hook.TypeScheduledReminder),
},
// a hook keeps its type for life, so complete settings of another
// type are refused once the hook is known.
"Another type's settings are refused": {
DB: stubHookDB(),
Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"url-watcher","url":"https://example.com"}}`,
- Err: fmt.Errorf("update_hook: %w", errHookTypeMismatch(hook.TypeScheduledReminder, hook.TypeURLWatcher)),
+ Err: errors.New("the hook is a scheduled-reminder, not a url-watcher; to change the type, delete it and create another"),
},
"Error returned by hookMan.UpdateHook": {
- DB: stubHookDB(),
- Man: failing,
- Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
- Err: assert.AnError,
+ DB: stubHookDB(),
+ Man: failingUpdate,
+ Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
+ Updates: 1,
+ Err: assert.AnError,
+ },
+ "Error returned by hookMan.ResetHook": {
+ DB: stubHookDB(),
+ Man: failingReset,
+ Args: `{` + hookArgs(_testHookID) + `}`,
+ Resets: 1,
+ Err: assert.AnError,
},
"Updated": {
- DB: stubHookDB(),
- Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
+ DB: stubHookDB(),
+ Args: `{` + hookArgs(_testHookID) + `,"settings":{"type":"scheduled-reminder","schedule":"` + later.Format(time.RFC3339) + `"}}`,
+ Updates: 1,
+ Settings: `{"scale":"linear","duration":"custom","schedule":"2031-06-01T12:00:00Z"}`,
+ },
+ "Reset, keeping the settings": {
+ DB: stubHookDB(),
+ Args: `{` + hookArgs(_testHookID) + `}`,
+ Resets: 1,
+ Settings: string(stubHook().Settings),
},
}
@@ -757,21 +807,18 @@ func Test_updateHook_Execute(t *testing.T) {
res, err := updateHook{}.Execute(inp)
testutil.AssertEqualError(t, c.Err, err)
+ man := d.hookMan.(*HookManagerMock)
+ assert.Len(t, man.UpdateHookCalls(), c.Updates)
+ assert.Len(t, man.ResetHookCalls(), c.Resets)
+
if err != nil {
return
}
- ff := d.hookMan.(*HookManagerMock).UpdateHookCalls()
- require.Len(t, ff, 1)
- assert.Equal(t, _testHookID, ff[0].ID)
- assert.JSONEq(t, `{"scale":"linear","duration":"custom","schedule":"2031-06-01T12:00:00Z"}`, string(ff[0].UI.Settings))
-
row := decodeHookRow(t, res)
assert.Equal(t, _testHookID, row.ID)
- assert.JSONEq(t, string(ff[0].UI.Settings), string(row.Settings))
+ assert.JSONEq(t, c.Settings, string(row.Settings))
assert.Equal(t, null.TimeFrom(_stubScheduleTime), row.UpdatedAt)
-
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
})
}
}
@@ -788,157 +835,19 @@ func Test_hookRefArgs_Validate(t *testing.T) {
)
}
-func Test_hookRefProps(t *testing.T) {
- t.Parallel()
-
- got := hookRefProps()
-
- assert.Contains(t, got, "document_id")
- assert.Contains(t, got, "hook_id")
-}
-
-func Test_resetHook_Info(t *testing.T) {
- t.Parallel()
-
- info := resetHook{}.Info()
-
- assert.Equal(t, NameResetHook, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Equal(t, []string{"document_id", "hook_id"}, info.Required)
- assert.Contains(t, info.Properties, "hook_id")
-}
-
-func Test_resetHook_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, resetHook{}.Traits())
-}
-
-func Test_resetHook_Title(t *testing.T) {
- t.Parallel()
-
- got, err := resetHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_testHookID)+`}`))
- require.NoError(t, err)
- assert.Equal(t, "Resetting a hook on Runbook", got)
-
- _, err = resetHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameResetHook, requiredArgs(t, NameResetHook)))
- require.Error(t, err)
-
- _, err = resetHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`))
- require.Error(t, err)
-}
-
-func Test_resetHook_Summary(t *testing.T) {
- t.Parallel()
-
- got, err := resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_testHookID)+`}`))
- require.NoError(t, err)
- assert.Equal(t, ActionSummary{
- Tool: NameResetHook,
- DocumentID: _testDocID,
- DocumentName: "Runbook",
- Summary: "Reset the Scheduled Reminder hook on Runbook on branch draft",
- }, got)
-
- _, err = resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`+hookArgs(_unknownHookID)+`}`))
- require.Error(t, err)
-
- _, err = resetHook{}.Summary(testInput(testDeps(stubHookDB(), nil, nil), NameResetHook, `{`))
- require.Error(t, err)
-}
-
-func Test_resetHook_Execute(t *testing.T) {
- t.Parallel()
-
- failing := stubHookManager()
- failing.ResetHookFunc = func(context.Context, xid.ID, xid.ID, string) (*hook.Hook, error) {
- return nil, assert.AnError
- }
-
- cc := map[string]struct {
- DB *DBMock
- Man *HookManagerMock
- Args string
- Err error
- }{
- "Malformed arguments": {DB: stubHookDB(), Args: `{`, Err: assert.AnError},
- "Hook id is required": {DB: stubHookDB(), Args: `{"document_id":"` + _testDocID.String() + `"}`, Err: assert.AnError},
- "Unknown hook": {
- DB: stubHookDB(),
- Args: `{` + hookArgs(_unknownHookID) + `}`,
- Err: fmt.Errorf("reset_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
- },
- "Error returned by hookMan.ResetHook": {
- DB: stubHookDB(),
- Man: failing,
- Args: `{` + hookArgs(_testHookID) + `}`,
- Err: assert.AnError,
- },
- "Reset": {
- DB: stubHookDB(),
- Args: `{` + hookArgs(_testHookID) + `}`,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- d := hookDeps(c.DB, c.Man)
- inp := testInput(d, NameResetHook, c.Args)
-
- res, err := resetHook{}.Execute(inp)
- testutil.AssertEqualError(t, c.Err, err)
-
- if err != nil {
- return
- }
-
- ff := d.hookMan.(*HookManagerMock).ResetHookCalls()
- require.Len(t, ff, 1)
- assert.Equal(t, _testHookID, ff[0].ID)
-
- row := decodeHookRow(t, res)
- assert.Equal(t, _testHookID, row.ID)
- assert.JSONEq(t, string(stubHook().Settings), string(row.Settings))
- assert.Equal(t, null.TimeFrom(_stubScheduleTime), row.UpdatedAt)
-
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
- })
- }
-}
-
func Test_deleteHook_Info(t *testing.T) {
t.Parallel()
info := deleteHook{}.Info()
+ assert.Equal(t, Traits{Write: true, Destructive: true}, info.Traits)
+
assert.Equal(t, NameDeleteHook, info.Name)
assert.NotEmpty(t, info.Description)
assert.Equal(t, []string{"document_id", "hook_id"}, info.Required)
assert.Contains(t, info.Properties, "hook_id")
}
-func Test_deleteHook_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true, Destructive: true}, deleteHook{}.Traits())
-}
-
-func Test_deleteHook_Title(t *testing.T) {
- t.Parallel()
-
- got, err := deleteHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`+hookArgs(_testHookID)+`}`))
- require.NoError(t, err)
- assert.Equal(t, "Deleting a hook on Runbook", got)
-
- _, err = deleteHook{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameDeleteHook, requiredArgs(t, NameDeleteHook)))
- require.Error(t, err)
-
- _, err = deleteHook{}.Title(testInput(testDeps(stubHookDB(), nil, nil), NameDeleteHook, `{`))
- require.Error(t, err)
-}
-
func Test_deleteHook_Summary(t *testing.T) {
t.Parallel()
@@ -977,7 +886,7 @@ func Test_deleteHook_Execute(t *testing.T) {
"Unknown hook": {
DB: stubHookDB(),
Args: `{` + hookArgs(_unknownHookID) + `}`,
- Err: fmt.Errorf("delete_hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
+ Err: fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook),
},
"Error returned by hookMan.DeleteHook": {
DB: stubHookDB(),
@@ -1011,12 +920,11 @@ func Test_deleteHook_Execute(t *testing.T) {
assert.JSONEq(t, `{"hook_id":"`+_testHookID.String()+`","deleted":true}`, res)
// the branch stays, and the editor showing it has to redraw.
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
})
}
}
-func Test_describeHook(t *testing.T) {
+func Test_hookRefArgs_fetchHookAndBranch(t *testing.T) {
t.Parallel()
branchless := stubHookDB()
@@ -1041,7 +949,7 @@ func Test_describeHook(t *testing.T) {
"Unknown hook": {
DB: stubHookDB(),
HookID: _unknownHookID,
- Err: fmt.Errorf("%s: fetch hook: %w", NameResetHook, fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
+ Err: fmt.Errorf("fetch hook: %w", fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook)),
},
"Error returned by the branch fetch": {
DB: failingDoc,
@@ -1066,7 +974,7 @@ func Test_describeHook(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- doc, hk, err := describeHook(testInput(testDeps(c.DB, nil, nil), NameResetHook, `{}`), NameResetHook, hookRefArgs{DocumentID: _testDocID, HookID: c.HookID})
+ doc, hk, err := hookRefArgs{DocumentID: _testDocID, HookID: c.HookID}.fetchHookAndBranch(testInput(testDeps(c.DB, nil, nil), NameUpdateHook, `{}`))
testutil.AssertEqualError(t, c.Err, err)
if err != nil {
diff --git a/server/core/internal/assistant/tools/input.go b/server/core/internal/assistant/tools/input.go
index 89bb2acc..30593568 100644
--- a/server/core/internal/assistant/tools/input.go
+++ b/server/core/internal/assistant/tools/input.go
@@ -8,14 +8,12 @@ import (
"errors"
"fmt"
"log/slog"
- "maps"
"slices"
"strings"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/github"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
"github.com/oxynote/oxynote/server/core/internal/datasource"
"github.com/oxynote/oxynote/server/core/internal/document"
@@ -43,7 +41,7 @@ var errUnknownHook = errors.New("no hook with that id on this document; call lis
// tool reaches through and the (organization, user) pair every call is
// scoped to.
//
-// It is built once per session. The per-call Input a tool actually sees
+// It is built once per session. The per-call input a tool actually sees
// is assembled from it by the eino adapter, which is the only thing
// that knows a call's context and arguments.
type Deps struct {
@@ -142,14 +140,13 @@ func NewDeps(
}
// input is one tool call: the session's wiring plus the context and
-// arguments of the call being served. It satisfies both DescribeInput
-// and Input; which of the two a tool receives is decided by the method
-// being called, not by what is in here.
+// arguments of the call being served. Execute receives it whole; Title
+// and Summary receive it as a DescribeInput, which reaches nothing that
+// writes.
type input struct {
*Deps
- // name is the tool being called, so a rejected argument can say
- // which tool rejected it.
+ // name is the tool being called, so a warning names it.
name Name
// ctx is the context of this call. It carries the agent session
@@ -160,22 +157,10 @@ type input struct {
// args is the raw JSON the model supplied.
args json.RawMessage
- // touched lists the branches this call changed, in the order the
- // writes below recorded them.
- touched []Touched
-}
-
-// recordTouched notes a branch this call changed. Every write in this
-// package goes through one of the four methods below, so recording it
-// here is what makes Result.Documents right by construction rather than
-// by a convention about argument names.
-func (i *input) recordTouched(documentID, branchID xid.ID) {
- t := Touched{DocumentID: documentID, BranchID: branchID}
- if documentID.IsNil() || branchID.IsNil() || slices.Contains(i.touched, t) {
- return
- }
-
- i.touched = append(i.touched, t)
+ // branches holds the branches this call has fetched, by branch id.
+ // A call reads its branch in several steps, and they share one
+ // fetch.
+ branches map[xid.ID]*document.Document
}
// newInput creates a fresh instance of input for one tool call.
@@ -188,8 +173,7 @@ func (i *input) Context() context.Context {
return i.ctx
}
-// Decode decodes the call's arguments into dst and validates them,
-// naming the tool that rejected them.
+// Decode decodes the call's arguments into dst and validates them.
//
// Decoding uses json/v2 because its errors name the argument they
// failed on. A domain type that parses itself — an id, a timestamp, an
@@ -206,24 +190,10 @@ func (i *input) Decode(dst Args) error {
}
if err := jsonv2.Unmarshal(args, dst); err != nil {
- return fmt.Errorf("%s: invalid input: %w", i.name, err)
+ return fmt.Errorf("invalid input: %w", err)
}
- if err := dst.Validate(); err != nil {
- return fmt.Errorf("%s: %w", i.name, err)
- }
-
- return nil
-}
-
-// OrganizationID returns the organisation every call is scoped to.
-func (i *input) OrganizationID() string {
- return i.orgID
-}
-
-// UserID returns the user the assistant is acting for.
-func (i *input) UserID() string {
- return i.userID
+ return dst.Validate()
}
// FetchDataSource returns the data source the id names.
@@ -234,7 +204,11 @@ func (i *input) UserID() string {
func (i *input) FetchDataSource(dataSourceID xid.ID) (*datasource.DataSource, error) {
ds, err := i.db.FetchDataSource(i.ctx, dataSourceID, i.orgID)
if err != nil {
- return nil, errUnknownDataSource
+ if errutil.IsNotFound(err) {
+ return nil, errUnknownDataSource
+ }
+
+ return nil, fmt.Errorf("fetching data source: %w", err)
}
return ds, nil
@@ -245,24 +219,30 @@ func (i *input) FetchDataSources() ([]datasource.DataSource, error) {
return i.db.FetchDataSources(i.ctx, i.orgID)
}
-// CheckDataSources refuses a write naming a data source the
-// organisation does not own.
+// CheckDataSources refuses blocks, nested ones included, that name a
+// data source the organisation does not own.
//
// It sits with the write rather than with the schema because it is the
-// only check that needs the database: block.Validate can say the id is
+// only check that needs the database: markup.Build can say the id is
// a string, but only a lookup can say it addresses something, and a
// metric block pointing at nothing renders as a broken chart the user
-// then has to fix by hand. The ids are block content, not arguments, so
-// they arrive as the strings the document stores and are parsed here.
-func (i *input) CheckDataSources(ids []string) error {
- for _, raw := range ids {
- id, err := xid.FromString(raw)
- if err != nil {
- return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
+// then has to fix by hand. The ids are the strings the blocks store, so
+// they are parsed here.
+func (i *input) CheckDataSources(blocks []document.Block) error {
+ for _, b := range blocks {
+ if raw := b.Attrs.Get(document.AttrDataSourceID).String(); raw != "" {
+ id, err := xid.FromString(raw)
+ if err != nil {
+ return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
+ }
+
+ if _, err := i.FetchDataSource(id); err != nil {
+ return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
+ }
}
- if _, err := i.FetchDataSource(id); err != nil {
- return fmt.Errorf("metric %s %q: %w", document.AttrDataSourceID, raw, err)
+ if err := i.CheckDataSources(b.Content); err != nil {
+ return err
}
}
@@ -270,15 +250,11 @@ func (i *input) CheckDataSources(ids []string) error {
}
// DataSourceRunner returns the runner that reads the data source the id
-// names.
-//
-// The lookup is the cross-org safety check: FetchDataSource scopes by
-// organisation, so an id belonging to another one is as absent as an id
-// belonging to nobody, and the model is told the same thing either way.
+// names, looked up through FetchDataSource.
func (i *input) DataSourceRunner(id xid.ID) (datasource.Runner, error) {
- ds, err := i.db.FetchDataSource(i.ctx, id, i.orgID)
+ ds, err := i.FetchDataSource(id)
if err != nil {
- return nil, errUnknownDataSource
+ return nil, err
}
return i.runners.Runner(*ds), nil
@@ -306,6 +282,10 @@ func (i *input) FetchDocument(documentID xid.ID) (*document.Document, error) {
// as unknown to this one, and an unknown branch is reported with the
// ones the document does have.
func (i *input) FetchBranch(documentID, branchID xid.ID) (*document.Document, error) {
+ if doc, ok := i.branches[branchID]; ok && doc.ID == documentID {
+ return doc, nil
+ }
+
doc, err := i.db.FetchDocumentByBranchID(i.ctx, branchID, i.orgID)
if err != nil {
if errutil.IsNotFound(err) {
@@ -319,6 +299,12 @@ func (i *input) FetchBranch(documentID, branchID xid.ID) (*document.Document, er
return nil, i.unknownBranch(documentID, branchID)
}
+ if i.branches == nil {
+ i.branches = map[xid.ID]*document.Document{}
+ }
+
+ i.branches[branchID] = doc
+
return doc, nil
}
@@ -331,7 +317,7 @@ func (i *input) unknownBranch(documentID, branchID xid.ID) error {
return err
}
- return fmt.Errorf("branch %s: %w; the branches are %s", branchID, ErrUnknownBranch, strings.Join(branchLabels(branches, nil), ", "))
+ return fmt.Errorf("branch %s: %w; the branches are %s", branchID, ErrUnknownBranch, branchLabels(branches))
}
// FetchDocumentBranches lists every branch of the document. A document the
@@ -350,33 +336,15 @@ func (i *input) FetchDocumentBranches(documentID xid.ID) ([]document.BranchSumma
return branches, nil
}
-// FetchDocumentContent returns the parsed content of the branch branchID
-// names. The branch fetch already carries the content, so this is the
-// same lookup in the shape the block walks take.
-func (i *input) FetchDocumentContent(documentID, branchID xid.ID) (document.Content, error) {
- doc, err := i.FetchBranch(documentID, branchID)
- if err != nil {
- return document.Content{}, err
- }
-
- return document.Content{
- OrganizationID: i.orgID,
- DocumentID: doc.ID,
- DocumentName: doc.DocumentName,
- Content: doc.Content,
- }, nil
-}
-
-// FetchDocumentBlock finds one block of the branch by uid. It reads the
-// persisted content, which is what every placement check reads too, so
-// a write and its checks see the same document.
+// FetchDocumentBlock finds one block of the branch by uid, in the
+// persisted content.
func (i *input) FetchDocumentBlock(documentID, branchID xid.ID, blockUID string) (document.Block, error) {
- content, err := i.FetchDocumentContent(documentID, branchID)
+ doc, err := i.FetchBranch(documentID, branchID)
if err != nil {
return document.Block{}, fmt.Errorf("fetching content: %w", err)
}
- b, ok := content.Content.FindByUID(blockUID)
+ b, ok := doc.Content.FindByUID(blockUID)
if !ok {
return document.Block{}, fmt.Errorf("block %s: %w", blockUID, errUnknownBlock)
}
@@ -423,7 +391,11 @@ func (i *input) FetchDocumentChildren(parentID null.Value[xid.ID]) (document.Sum
func (i *input) CreateDocument(doc document.Document) error {
if doc.ParentID.Valid {
if err := i.db.CheckDocumentExists(i.ctx, doc.ParentID.V, i.orgID); err != nil {
- return fmt.Errorf("parent %s: %w", doc.ParentID.V, errUnknownParent(err))
+ if errutil.IsNotFound(err) {
+ return fmt.Errorf("parent %s: %w", doc.ParentID.V, errUnknownParent)
+ }
+
+ return fmt.Errorf("checking parent: %w", err)
}
}
@@ -456,7 +428,7 @@ func (i *input) CreateDocument(doc document.Document) error {
}
i.searchTrigger.Trigger()
- i.recordTouched(doc.ID, doc.BranchID)
+ i.notifyTreeChange(doc.ParentID)
return nil
}
@@ -470,12 +442,10 @@ var errCyclicParent = errors.New("a document cannot be moved under itself or one
// current uids, since a stale uid is the usual cause.
var errUnknownBlock = errors.New("no block with that uid in this document; call get_document for the current uids")
-// errUnknownParent wraps a parent lookup failure so the model learns
-// where the ids it may use come from; a missing parent is otherwise a
-// bare not-found.
-func errUnknownParent(err error) error {
- return fmt.Errorf("no document with that id to use as parent; call list_documents for the ids that exist: %w", err)
-}
+// errUnknownParent is what a create or move reports for a parent id
+// that names no document in the organisation. It says where the ids the
+// model may use come from.
+var errUnknownParent = errors.New("no document with that id to use as parent; call list_documents for the ids that exist")
// ErrUnknownDocument is what a document lookup reports for an id that
// names nothing in the session's organisation. Another organisation's
@@ -483,14 +453,20 @@ func errUnknownParent(err error) error {
// discover that a document exists elsewhere.
var ErrUnknownDocument = errors.New("no document with that id in this organisation; call list_documents for the ids that exist")
-// DeleteDocument removes the document. It records nothing as touched:
-// the document is gone, so there is nothing left to point a caller at.
-// The delete reports the ids of the destroyed subtree, and their
-// search-index removal is queued in the same transaction: after the
-// commit nothing else knows what went away. An empty subtree means the
-// delete matched nothing, which is reported as an error rather than a
-// silent success.
+// DeleteDocument removes the document. The delete reports the ids of the
+// destroyed subtree, and their search-index removal is queued in the
+// same transaction: after the commit nothing else knows what went away.
+// An empty subtree means the delete matched nothing, which is reported
+// as an error rather than a silent success.
func (i *input) DeleteDocument(id xid.ID) error {
+ // the parent is read before the row goes, to tell the subtree that
+ // changed. A document that cannot be read leaves the root's.
+ var parentID null.Value[xid.ID]
+
+ if doc, err := i.FetchDocument(id); err == nil {
+ parentID = doc.ParentID
+ }
+
var tx Tx
if err := i.db.BeginTx(i.ctx, &tx); err != nil {
@@ -519,6 +495,7 @@ func (i *input) DeleteDocument(id xid.ID) error {
}
i.searchTrigger.Trigger()
+ i.notifyTreeChange(parentID)
return nil
}
@@ -530,9 +507,13 @@ func (i *input) DeleteDocument(id xid.ID) error {
// subtree, is a broken tree, and the check belongs with the write that
// would cause it.
func (i *input) MoveDocument(doc *document.Document, parentID null.Value[xid.ID]) error {
- if parentID.Valid {
+ if parentID.Valid { //nolint:nestif // the branching is sequential and readable
if err := i.db.CheckDocumentExists(i.ctx, parentID.V, i.orgID); err != nil {
- return fmt.Errorf("new parent %s: %w", parentID.V, errUnknownParent(err))
+ if errutil.IsNotFound(err) {
+ return fmt.Errorf("new parent %s: %w", parentID.V, errUnknownParent)
+ }
+
+ return fmt.Errorf("checking parent: %w", err)
}
cycle, err := i.db.CheckDocumentCycle(i.ctx, doc.ID, parentID.V, i.orgID)
@@ -549,17 +530,42 @@ func (i *input) MoveDocument(doc *document.Document, parentID null.Value[xid.ID]
return fmt.Errorf("update: %w", err)
}
- // the parents' own content is unchanged — only the tree shape
- // around them — so the moved document is the one to record, on the
- // branch it was fetched on.
- i.recordTouched(doc.ID, doc.BranchID)
+ // the move changes the shape of both subtrees; when they are the
+ // same one, one notification covers it.
+ if doc.ParentID != parentID {
+ i.notifyTreeChange(doc.ParentID)
+ }
+
+ i.notifyTreeChange(parentID)
return nil
}
-// SearchBlocks returns blocks whose text matches the query.
-func (i *input) SearchBlocks(query string, limit int) ([]search.Block, error) {
- return i.search.SearchDocumentBlocks(i.ctx, i.orgID, query, limit)
+// RenameDocument sets the document's name and icon through the realtime
+// service, so an open editor shows them as they land. An empty name or
+// icon stays as it is.
+func (i *input) RenameDocument(doc *document.Document, name, icon string) error {
+ var ops []edit.Operation
+
+ if name != "" {
+ ops = append(ops, edit.SetName(name))
+ }
+
+ if icon != "" {
+ ops = append(ops, edit.SetIcon(icon))
+ }
+
+ if len(ops) == 0 {
+ return nil
+ }
+
+ if err := i.ApplyEdit(doc.ID, doc.BranchID, ops); err != nil {
+ return err
+ }
+
+ i.notifyTreeChange(doc.ParentID)
+
+ return nil
}
// Warn records something a tool carried on through.
@@ -568,12 +574,6 @@ func (i *input) Warn(msg string, attrs ...slog.Attr) {
append([]slog.Attr{slog.String("tool", string(i.name))}, attrs...)...)
}
-// ReadOffloaded returns a tool result that was moved out of the
-// conversation for size.
-func (i *input) ReadOffloaded(path string) (string, error) {
- return i.offload.Read(i.ctx, path)
-}
-
// errBranchProtected is what a content write reports for a branch that
// is protected. Reading it stays allowed; the caller is told which of
// the document's branches would take the write instead.
@@ -584,28 +584,6 @@ var errBranchProtected = errors.New("this branch is protected and takes no edits
// a stale or guessed id is the usual cause.
var ErrUnknownBranch = errors.New("this document has no branch with that id")
-// resolveDoc loads the branch branchID names of the given document and
-// returns the ids the edit client needs. The lookup
-// also acts as the cross-org safety check — Document scopes by orgID so
-// a docID from another organisation surfaces as NotFound.
-func (i *input) resolveDoc(documentID, branchID xid.ID) (docRef, error) {
- doc, err := i.FetchBranch(documentID, branchID)
- if err != nil {
- return docRef{}, fmt.Errorf("fetching document: %w", err)
- }
-
- // a protected branch takes no write but core's own, and this is not
- // one: the operations endpoint applies the batch to the live Y.Doc
- // regardless, and only the persist behind it is refused. Without
- // this the call reports success, the change shows up in every open
- // editor, and it is gone at the next load.
- if doc.Protected {
- return docRef{}, i.protectedBranch(doc)
- }
-
- return docRef{DocumentID: doc.ID, BranchID: doc.BranchID}, nil
-}
-
// protectedBranch builds the refusal for a write to a protected branch,
// naming the document's unprotected branches so the caller has
// somewhere to write, or saying there is none.
@@ -615,221 +593,80 @@ func (i *input) protectedBranch(doc *document.Document) error {
return fmt.Errorf("branch %s: %w; %w", doc.BranchName, errBranchProtected, err)
}
- open := branchLabels(branches, func(b document.BranchSummary) bool { return !b.Protected })
+ open := slices.DeleteFunc(branches, func(b document.BranchSummary) bool { return b.Protected })
if len(open) == 0 {
return fmt.Errorf("branch %s: %w; the document has no unprotected branch to write to", doc.BranchName, errBranchProtected)
}
- return fmt.Errorf("branch %s: %w; write to one of %s", doc.BranchName, errBranchProtected, strings.Join(open, ", "))
+ return fmt.Errorf("branch %s: %w; write to one of %s", doc.BranchName, errBranchProtected, branchLabels(open))
}
-// branchLabels lists the branches keep accepts, or all of them when keep
-// is nil, each as "name (id)": the name is what a person calls it and
-// the id is what a tool call takes.
-func branchLabels(branches []document.BranchSummary, keep func(document.BranchSummary) bool) []string {
- out := make([]string, 0, len(branches))
+// branchLabels lists the branches, each as "name (id)": the name is what
+// a person calls it and the id is what a tool call takes.
+func branchLabels(branches []document.BranchSummary) string {
+ labels := make([]string, 0, len(branches))
for _, b := range branches {
- if keep == nil || keep(b) {
- out = append(out, fmt.Sprintf("%s (%s)", b.BranchName, b.BranchID))
- }
+ labels = append(labels, fmt.Sprintf("%s (%s)", b.BranchName, b.BranchID))
}
- return out
+ return strings.Join(labels, ", ")
}
// ApplyEdit is the shared tail of every content-mutating write tool: it
-// resolves the document and branch to a (documentID, branchID) pair, ships the
-// operation batch to Node, and reports any operation Node refused.
-// Outcomes are logged so partial failures on the Node side (uid not
-// found, malformed block) are visible without re-running the
-// conversation. What the write produced is the tool's own to describe;
-// the persist behind the live document is debounced, so a re-read here
-// would not reliably show it.
+// loads the branch, ships the operation batch to Node, and returns the
+// operation Node refused, if any, in which case nothing was applied. What
+// the write produced is the tool's own to describe, which spares a second
+// fetch of the document. The branch lookup is scoped to the organisation,
+// so a document of another one is refused as unknown.
func (i *input) ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) error {
- ref, err := i.resolveDoc(documentID, branchID)
+ doc, err := i.FetchBranch(documentID, branchID)
if err != nil {
- i.log.Warn(
- "edit resolve failed",
- slog.String("document_id", documentID.String()),
- slog.String("error", err.Error()),
- )
+ return fmt.Errorf("fetching document: %w", err)
+ }
- return err
+ // a protected branch takes no write but core's own, and this is not
+ // one: the operations endpoint applies the batch to the live Y.Doc
+ // regardless, and only the persist behind it is refused. Without
+ // this the call reports success, the change shows up in every open
+ // editor, and it is gone at the next load.
+ if doc.Protected {
+ return i.protectedBranch(doc)
}
res, err := i.applier.Apply(
i.ctx,
- ref.DocumentID,
- ref.BranchID,
+ documentID,
+ branchID,
ops,
i.userID,
false,
)
if err != nil {
- i.log.Error(
- "edit apply failed",
- slog.String("document_id", ref.DocumentID.String()),
- slog.String("branch_id", ref.BranchID.String()),
- slog.Int("op_count", len(ops)),
- slog.String("error", err.Error()),
- )
-
return fmt.Errorf("applying edit: %w", err)
}
- // nothing committed and something to say why is a failed call, not
- // a result describing a no-op: every write tool ships a single
- // operation, so there is no partial success to report. Returning an
- // error is what makes the surfaces treat it as one — the MCP bridge
- // marks the result isError, and the assistant sees a failure it can
- // correct rather than a success it will summarise.
- if res.Applied == 0 && len(res.Errors) > 0 {
- return fmt.Errorf("applying edit: %s", edit.JoinOpErrors(res.Errors))
+ // an operation refused is a failed call: the MCP bridge then marks
+ // the result isError, and the assistant sees a failure it can
+ // correct. A batch applies whole or not at all, so nothing landed.
+ if err := res.Err(len(ops)); err != nil {
+ return fmt.Errorf("applying edit: %w", err)
}
- if len(res.Errors) > 0 {
- i.log.Warn(
- "edit partial failure",
- slog.String("document_id", ref.DocumentID.String()),
- slog.String("branch_id", ref.BranchID.String()),
- slog.Int("applied", res.Applied),
- slog.Any("errors", res.Errors),
- )
- } else {
- i.log.Debug(
- "edit applied",
- slog.String("document_id", ref.DocumentID.String()),
- slog.String("branch_id", ref.BranchID.String()),
- slog.Int("applied", res.Applied),
- )
- }
-
- // the resolved id, not the argument: the caller may have named the
- // document any way resolveDoc accepts.
- i.recordTouched(ref.DocumentID, ref.BranchID)
+ i.log.Debug(
+ "edit applied",
+ slog.String("document_id", documentID.String()),
+ slog.String("branch_id", branchID.String()),
+ slog.Int("op_count", len(ops)),
+ )
return nil
}
-// ValidatePlacement validates a block that is about to land next to, or
-// in place of, the block referenceUID names. The reference's parent is
-// what decides legality — the document root takes the root set, a macro
-// container takes its own — so the check resolves the reference in the
-// fetched content and validates against that container. The edit
-// backend applies no schema of its own, so an illegal type let through
-// here would land in the Y.Doc unchallenged.
-func (i *input) ValidatePlacement(documentID, branchID xid.ID, referenceUID string, b block.Block) error {
- content, err := i.FetchDocumentContent(documentID, branchID)
- if err != nil {
- return fmt.Errorf("fetching content: %w", err)
- }
-
- parent, ok := content.Content.FindParentTypeByUID(referenceUID)
- if !ok {
- // the reference does not resolve; the edit backend reports
- // that as the call's result. Still reject a malformed block.
- return block.Validate(b)
- }
-
- return block.ValidateInContainer(parent, b)
-}
-
-// ValidateAttrUpdate checks the attributes an update would leave on the
-// block. The update names some attributes and preserves the rest, so
-// the rules run against the merge rather than the payload — otherwise
-// setting one attribute would be judged as if every other were absent.
-//
-// A block whose ProseMirror type has no canonical counterpart is a
-// wrapper item (a list item, a macro internal). Those carry attributes
-// the canonical model does not describe, so there is nothing to check.
-func (i *input) ValidateAttrUpdate(documentID, branchID xid.ID, blockUID string, attrs map[string]any) error {
- content, err := i.FetchDocumentContent(documentID, branchID)
- if err != nil {
- return fmt.Errorf("fetching content: %w", err)
- }
-
- target, ok := content.Content.FindByUID(blockUID)
- if !ok {
- // the uid does not resolve; the edit backend reports that as
- // the call's result.
- return nil
- }
-
- t, ok := block.CanonicalType(target.Type)
- if !ok {
- return nil
- }
-
- current := target.Attrs
-
- // a titled code block keeps its title and language on its children
- // rather than on itself.
- if target.Type == document.BlockNodeTitledCodeBlock {
- c, err := block.Compact(target)
- if err != nil {
- // NOCOV: compacting a titled code block cannot fail.
- return fmt.Errorf("compacting block: %w", err)
- }
-
- current = c.Attrs
- }
-
- merged := make(document.Attributes, len(current)+len(attrs))
- maps.Copy(merged, current)
- maps.Copy(merged, attrs)
-
- return block.ValidateAttrs(t, merged)
-}
-
-// ValidateMove checks that the moved block may live where the move
-// would put it: among the children of whatever holds the reference.
-// Only placement is in question — the block is already in the document,
-// so its content has been through validation once already and may
-// contain shapes this layer can no longer express.
-//
-// A wrapper item (a list item, a macro internal) has no canonical type
-// to check against a container's allowed set, so it is held to a
-// narrower rule instead: it may only land in a container of the kind it
-// already sits in, which permits reordering and moving between two
-// lists while keeping a list item from landing at the document root.
-func (i *input) ValidateMove(documentID, branchID xid.ID, blockUID, referenceUID string) error {
- content, err := i.FetchDocumentContent(documentID, branchID)
- if err != nil {
- return fmt.Errorf("fetching content: %w", err)
- }
-
- moved, ok := content.Content.FindByUID(blockUID)
- if !ok {
- // neither uid resolving is the edit backend's report to make.
- return nil
- }
-
- target, ok := content.Content.FindParentTypeByUID(referenceUID)
- if !ok {
- return nil
- }
-
- t, ok := block.CanonicalType(moved.Type)
- if !ok {
- source, ok := content.Content.FindParentTypeByUID(blockUID)
- if !ok || source == target {
- return nil
- }
-
- return fmt.Errorf(
- "%s cannot move from %s into %s; it belongs to the block that holds it",
- moved.Type, source, target,
- )
- }
-
- return block.AllowedInContainer(target, t)
-}
-
-// NotifyTreeChange invokes the tree notifier when one is configured.
+// notifyTreeChange invokes the tree notifier when one is configured.
// Safe to call from any tool — a nil notifier silently no-ops so tests
// that don't wire one don't trip.
-func (i *input) NotifyTreeChange(parentID null.Value[xid.ID]) {
+func (i *input) notifyTreeChange(parentID null.Value[xid.ID]) {
if i.tree == nil {
return
}
@@ -837,19 +674,6 @@ func (i *input) NotifyTreeChange(parentID null.Value[xid.ID]) {
i.tree.NotifyTreeChange(i.orgID, parentID)
}
-// NotifyTreeChangeForDocument looks up the document's current parent
-// and fires a tree-change for that parent. Used by rename/icon ops
-// which don't carry a parent in their args. Failures (e.g. doc fetched
-// after delete) silently skip the notification.
-func (i *input) NotifyTreeChangeForDocument(documentID xid.ID) {
- doc, err := i.FetchDocument(documentID)
- if err != nil || doc == nil {
- return
- }
-
- i.NotifyTreeChange(doc.ParentID)
-}
-
// FetchTagTree returns every tag in the organisation in display order, each
// with the documents whose default branch carries it and whether the
// session's user hides it.
@@ -885,7 +709,13 @@ func (i *input) FetchBranchTags(documentID, branchID xid.ID) ([]tag.Tag, error)
// name the organisation already uses is refused in the repository's own
// words, which name the clash.
func (i *input) CreateTag(t tag.Tag) error {
- return i.db.InsertTag(i.ctx, t)
+ if err := i.db.InsertTag(i.ctx, t); err != nil {
+ return err
+ }
+
+ i.notifyTagTreeChange()
+
+ return nil
}
// UpdateTag renames and/or recolours the tag the id names.
@@ -898,6 +728,8 @@ func (i *input) UpdateTag(tagID xid.ID, inp tag.UpdateInput) error {
return err
}
+ i.notifyTagTreeChange()
+
return nil
}
@@ -912,6 +744,8 @@ func (i *input) DeleteTag(tagID xid.ID) error {
return err
}
+ i.notifyTagTreeChange()
+
return nil
}
@@ -932,7 +766,8 @@ func (i *input) AssignTag(documentID, branchID, tagID xid.ID) error {
return err
}
- i.recordTouched(doc.ID, doc.BranchID)
+ i.notifyTagTreeChange()
+ i.notifyBranchTagsChange(doc.ID, doc.BranchID)
return nil
}
@@ -955,7 +790,8 @@ func (i *input) UnassignTag(documentID, branchID, tagID xid.ID) error {
return err
}
- i.recordTouched(doc.ID, doc.BranchID)
+ i.notifyTagTreeChange()
+ i.notifyBranchTagsChange(doc.ID, doc.BranchID)
return nil
}
@@ -978,12 +814,18 @@ func (i *input) MoveTag(tagID xid.ID, sortIndex int) error {
return err
}
- return i.db.UpdateTagTree(i.ctx, moved, i.orgID)
+ if err := i.db.UpdateTagTree(i.ctx, moved, i.orgID); err != nil {
+ return err
+ }
+
+ i.notifyTagTreeChange()
+
+ return nil
}
-// NotifyTagTreeChange invokes the tag notifier when one is configured.
+// notifyTagTreeChange invokes the tag notifier when one is configured.
// A nil notifier silently no-ops, like the document tree's.
-func (i *input) NotifyTagTreeChange() {
+func (i *input) notifyTagTreeChange() {
if i.tags == nil {
return
}
@@ -991,9 +833,9 @@ func (i *input) NotifyTagTreeChange() {
i.tags.NotifyTreeChange(i.orgID)
}
-// NotifyBranchTagsChange invokes the tag notifier for one branch's tags
+// notifyBranchTagsChange invokes the tag notifier for one branch's tags
// when one is configured. A nil notifier silently no-ops.
-func (i *input) NotifyBranchTagsChange(documentID, branchID xid.ID) {
+func (i *input) notifyBranchTagsChange(documentID, branchID xid.ID) {
if i.tags == nil {
return
}
@@ -1025,25 +867,19 @@ func (i *input) FetchHook(documentID, hookID xid.ID) (*hook.Hook, error) {
hk, err := i.db.FetchDocumentHook(i.ctx, hookID, i.orgID)
if err != nil {
if errutil.IsNotFound(err) {
- return nil, i.unknownHook(documentID, hookID)
+ return nil, fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook)
}
return nil, fmt.Errorf("fetching hook: %w", err)
}
if !hk.DocumentID.Valid || hk.DocumentID.V != documentID {
- return nil, i.unknownHook(documentID, hookID)
+ return nil, fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook)
}
return hk, nil
}
-// unknownHook builds the refusal for a hook the document does not hold,
-// naming the document the call addressed.
-func (i *input) unknownHook(documentID, hookID xid.ID) error {
- return fmt.Errorf("hook %s on document %s: %w", hookID, documentID, errUnknownHook)
-}
-
// CreateHook creates a hook on the branch branchID names, anchored to the
// block blockUID names when one is given. The branch is checked here, so
// a hook cannot land on a branch of another document. A type whose
@@ -1083,8 +919,6 @@ func (i *input) CreateHook(documentID, branchID xid.ID, blockUID string, tp hook
return nil, err
}
- i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
-
return hk, nil
}
@@ -1098,8 +932,6 @@ func (i *input) UpdateHook(hk *hook.Hook, settings processor.Settings) error {
*hk = *updated
- i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
-
return nil
}
@@ -1112,34 +944,19 @@ func (i *input) ResetHook(hk *hook.Hook) error {
*hk = *reset
- i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
-
return nil
}
// DeleteHook tears down what the hook holds outside the document and
-// removes its row. The branch stays, and the editor showing it has to
-// redraw, so the delete records the branch as touched.
+// removes its row.
func (i *input) DeleteHook(hk *hook.Hook) error {
if err := i.hookMan.DeleteHook(i.ctx, hk.ID, hk.DocumentID.V, i.orgID, i.userID); err != nil {
return err
}
- i.recordTouched(hk.DocumentID.V, hk.BranchID.V)
-
return nil
}
-// docRef wraps the (documentID, branchID) pair the edit client needs to
-// address a live Y.Doc.
-type docRef struct {
- // DocumentID is the document's id.
- DocumentID xid.ID
-
- // BranchID is the id of the addressed branch.
- BranchID xid.ID
-}
-
// DataSourceRunners hands out the runner for a data source. The
// datasource package's Manager satisfies it.
//
@@ -1241,15 +1058,15 @@ type TagDB interface {
DeleteTag(ctx context.Context, id xid.ID, organizationID string) error
// AssignBranchTag should make a document's branch carry a tag,
- // changing nothing when it already does. Used by assign_tag.
+ // changing nothing when it already does.
AssignBranchTag(ctx context.Context, organizationID string, documentID, branchID, tagID xid.ID) error
// UnassignBranchTag should stop a document's branch carrying a tag,
- // changing nothing when it does not. Used by unassign_tag.
+ // changing nothing when it does not.
UnassignBranchTag(ctx context.Context, organizationID string, documentID, branchID, tagID xid.ID) error
// UpdateTagTree should rewrite the display order of the org's tags to
- // the order of the given tree. Used by move_tag.
+ // the order of the given tree.
UpdateTagTree(ctx context.Context, tree tag.Summaries, organizationID string) error
}
diff --git a/server/core/internal/assistant/tools/input_test.go b/server/core/internal/assistant/tools/input_test.go
index 66b85909..32bcd1e9 100644
--- a/server/core/internal/assistant/tools/input_test.go
+++ b/server/core/internal/assistant/tools/input_test.go
@@ -3,6 +3,7 @@ package tools
import (
"bytes"
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
@@ -15,7 +16,6 @@ import (
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/github"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
"github.com/oxynote/oxynote/server/core/internal/datasource"
datasourceMock "github.com/oxynote/oxynote/server/core/internal/datasource/_mock"
@@ -163,8 +163,10 @@ func requiredArgs(t *testing.T, name Name) string {
vals[key] = "green"
case "sort_index":
vals[key] = 0
- case "block":
- vals[key] = map[string]any{"type": string(block.BlockParagraph)}
+ case "assigned":
+ vals[key] = true
+ case "content":
+ vals[key] = "x
"
case "position":
// a position beside a block needs the reference the tool
// requires; one that requires no reference takes an end.
@@ -174,14 +176,16 @@ func requiredArgs(t *testing.T, name Name) string {
}
case "branch_id":
vals[key] = _stubMainBranchID.String()
+ case "block_uid":
+ // a block the stubbed document holds, since a replace checks
+ // its markup against it before anything is shown.
+ vals[key] = "a"
case "reference_block_uid":
// distinct from the "x" other uid keys take, so a payload
// naming both a block and a reference is not a self-move.
vals[key] = "r"
case "matchers":
vals[key] = []string{"up"}
- case "attrs":
- vals[key] = map[string]any{"level": 2}
default:
vals[key] = "x"
}
@@ -380,7 +384,7 @@ func failingDocumentDB() *DBMock {
func stubApplier() *EditApplierMock {
return &EditApplierMock{
ApplyFunc: func(_ context.Context, _, _ xid.ID, _ []edit.Operation, _ string, _ bool) (edit.Result, error) {
- return edit.Result{Applied: 1, Errors: []edit.OpError{}}, nil
+ return edit.Result{Errors: []edit.OpError{}}, nil
},
}
}
@@ -439,8 +443,8 @@ func Test_Deps_newInput(t *testing.T) {
assert.Equal(t, NameGetDocument, i.name)
assert.Equal(t, ctx, i.Context())
assert.JSONEq(t, `{"a":1}`, string(i.args))
- assert.Equal(t, "org", i.OrganizationID())
- assert.Equal(t, "user", i.UserID())
+ assert.Equal(t, "org", i.orgID)
+ assert.Equal(t, "user", i.userID)
}
func Test_input_Decode(t *testing.T) {
@@ -449,13 +453,13 @@ func Test_input_Decode(t *testing.T) {
cc := map[string]struct {
Args string
Err string
- Want readBlockArgs
+ Want deleteBlockArgs
}{
- "Malformed JSON": {Args: `{`, Err: "read_block: invalid input:"},
+ "Malformed JSON": {Args: `{`, Err: "invalid input:"},
// a provider calling a parameterless tool sends nothing rather
// than an empty object; that is a payload, not a decode failure.
- "Empty arguments read as none": {Args: ``, Err: "read_block: document_id is required"},
- "Blank arguments read as none": {Args: " \n", Err: "read_block: document_id is required"},
+ "Empty arguments read as none": {Args: ``, Err: "document_id is required"},
+ "Blank arguments read as none": {Args: " \n", Err: "document_id is required"},
// NOTE: json/v2 randomizes the modal verb of its error messages per
// process ("cannot" / "unable to") to keep callers off the exact
// wording, so the expectation starts after it.
@@ -465,19 +469,19 @@ func Test_input_Decode(t *testing.T) {
},
"Null id is not an argument": {
Args: `{"document_id":null,"block_uid":"b"}`,
- Err: "read_block: document_id is required",
+ Err: "document_id is required",
},
"Incomplete arguments are rejected by Validate": {
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
- Err: "read_block: block_uid is required",
+ Err: "block_uid is required",
},
"Unknown keys are ignored": {
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"b","extra":true}`,
- Want: readBlockArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "b"},
+ Want: deleteBlockArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "b"},
},
"Decoded": {
Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"b"}`,
- Want: readBlockArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "b"},
+ Want: deleteBlockArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, BlockUID: "b"},
},
}
@@ -485,9 +489,9 @@ func Test_input_Decode(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- var out readBlockArgs
+ var out deleteBlockArgs
- err := testInput(testDeps(nil, nil, nil), NameReadBlock, c.Args).Decode(&out)
+ err := testInput(testDeps(nil, nil, nil), NameDeleteBlock, c.Args).Decode(&out)
if c.Err != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), c.Err)
@@ -552,6 +556,15 @@ func Test_input_CreateDocument(t *testing.T) {
return tcase{DB: db, Parent: null.ValueFrom(xid.New()), Err: assert.AnError}
}(),
+ "Unknown parent": func() tcase {
+ parentID := xid.New()
+ db := stubDB(nil, nil)
+ db.CheckDocumentExistsFunc = func(context.Context, xid.ID, string) error {
+ return sql.ErrNoRows
+ }
+
+ return tcase{DB: db, Parent: null.ValueFrom(parentID), Err: fmt.Errorf("parent %s: %w", parentID, errUnknownParent)}
+ }(),
"Error returned by db.BeginTx": func() tcase {
return tcase{DB: stubDB(nil, assert.AnError), Err: assert.AnError}
}(),
@@ -593,13 +606,6 @@ func Test_input_CreateDocument(t *testing.T) {
err := inp.CreateDocument(doc)
testutil.AssertEqualError(t, c.Err, err)
- // only a committed document exists to be pointed at.
- if err == nil {
- assert.Equal(t, []Touched{{DocumentID: doc.ID, BranchID: doc.BranchID}}, inp.touched)
- } else {
- assert.Empty(t, inp.touched)
- }
-
if c.Tx == nil {
return
}
@@ -617,6 +623,7 @@ func Test_input_DeleteDocument(t *testing.T) {
t.Parallel()
docID := xid.New()
+ parentID := null.ValueFrom(xid.New())
type tcase struct {
DB *DBMock
@@ -640,6 +647,9 @@ func Test_input_DeleteDocument(t *testing.T) {
stubDB := func(tx *TxMock, beginErr error) *DBMock {
return &DBMock{
+ FetchDocumentFunc: func(context.Context, xid.ID, string, string) (*document.Document, error) {
+ return &document.Document{ID: docID, ParentID: parentID}, nil
+ },
BeginTxFunc: func(_ context.Context, dest any) error {
if beginErr != nil {
return beginErr
@@ -690,15 +700,19 @@ func Test_input_DeleteDocument(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- inp := testInput(testDeps(c.DB, nil, nil), NameDeleteDocument, `{}`)
+ tree := &TreeNotifierMock{}
+ inp := testInput(testDeps(c.DB, nil, tree), NameDeleteDocument, `{}`)
err := inp.DeleteDocument(docID)
testutil.AssertEqualError(t, c.Err, err)
- // the document is gone, so nothing is recorded as touched —
- // a link to it would only point at something that no longer
- // exists.
- assert.Empty(t, inp.touched)
+ // only a committed delete changes the subtree it sat in.
+ if err != nil {
+ assert.Empty(t, tree.NotifyTreeChangeCalls())
+ } else {
+ require.Len(t, tree.NotifyTreeChangeCalls(), 1)
+ assert.Equal(t, parentID, tree.NotifyTreeChangeCalls()[0].ParentID)
+ }
if c.Tx == nil {
return
@@ -718,29 +732,75 @@ func Test_input_DeleteDocument(t *testing.T) {
}
}
-func Test_input_recordTouched(t *testing.T) {
+func Test_input_RenameDocument(t *testing.T) {
t.Parallel()
- inp := testInput(testDeps(nil, nil, nil), NameInsertBlock, `{}`)
+ parentID := null.ValueFrom(xid.New())
+ doc := &document.Document{ID: _testDocID, BranchID: _stubMainBranchID, ParentID: parentID}
- // a call that changed nothing reports nothing.
- assert.Empty(t, inp.touched)
+ failing := &EditApplierMock{
+ ApplyFunc: func(context.Context, xid.ID, xid.ID, []edit.Operation, string, bool) (edit.Result, error) {
+ return edit.Result{}, assert.AnError
+ },
+ }
- a, b, branch := xid.New(), xid.New(), xid.New()
+ cc := map[string]struct {
+ Applier *EditApplierMock
+ Name string
+ Icon string
+ Ops int
+ Notify int
+ Err error
+ }{
+ "Nothing to change": {Applier: stubApplier()},
+ "Error returned by applier.Apply": {
+ Applier: failing,
+ Name: "Playbook",
+ Err: assert.AnError,
+ },
+ "Renamed": {Applier: stubApplier(), Name: "Playbook", Ops: 1, Notify: 1},
+ "Renamed and re-iconed": {
+ Applier: stubApplier(),
+ Name: "Playbook",
+ Icon: "mingcute:rocket-fill",
+ Ops: 2,
+ Notify: 1,
+ },
+ }
- inp.recordTouched(a, branch)
- inp.recordTouched(b, branch)
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
- // a branch changed twice in one call is still one branch, and the
- // order the call touched them is preserved.
- inp.recordTouched(a, branch)
+ tree := &TreeNotifierMock{}
+ inp := testInput(testDeps(stubDocumentDB(), c.Applier, tree), NameUpdateDocument, `{}`)
- // nothing to record is not something to record, and neither is a
- // hook whose branch is gone.
- inp.recordTouched(xid.NilID(), branch)
- inp.recordTouched(a, xid.NilID())
+ err := inp.RenameDocument(doc, c.Name, c.Icon)
+ testutil.AssertEqualError(t, c.Err, err)
- assert.Equal(t, []Touched{{DocumentID: a, BranchID: branch}, {DocumentID: b, BranchID: branch}}, inp.touched)
+ // the document's own row shows the change, under its parent.
+ ff := tree.NotifyTreeChangeCalls()
+ require.Len(t, ff, c.Notify)
+
+ if c.Notify > 0 {
+ assert.Equal(t, parentID, ff[0].ParentID)
+ }
+
+ if err != nil {
+ return
+ }
+
+ calls := c.Applier.ApplyCalls()
+ if c.Ops == 0 {
+ assert.Empty(t, calls)
+
+ return
+ }
+
+ require.Len(t, calls, 1)
+ assert.Len(t, calls[0].Ops, c.Ops)
+ })
+ }
}
func Test_input_MoveDocument(t *testing.T) {
@@ -775,6 +835,12 @@ func Test_input_MoveDocument(t *testing.T) {
Checks: 1,
Err: assert.AnError,
},
+ "Unknown parent": {
+ DB: moveDB(sql.ErrNoRows, false, nil, nil),
+ Parent: parentID,
+ Checks: 1,
+ Err: fmt.Errorf("new parent %s: %w", parentID.V, errUnknownParent),
+ },
"Error returned by db.CheckDocumentCycle": {
DB: moveDB(nil, false, assert.AnError, nil),
Parent: parentID,
@@ -818,14 +884,6 @@ func Test_input_MoveDocument(t *testing.T) {
// nothing is written until the destination is known good.
assert.Len(t, c.DB.CheckDocumentExistsCalls(), c.Checks)
assert.Len(t, c.DB.UpdateDocumentParentIDCalls(), c.Updates)
-
- // the parents' own content is unchanged by a re-parent, so
- // the moved document is the only one recorded.
- if err == nil {
- assert.Equal(t, []Touched{{DocumentID: docID, BranchID: branchID}}, inp.touched)
- } else {
- assert.Empty(t, inp.touched)
- }
})
}
}
@@ -835,14 +893,18 @@ func Test_input_ApplyEdit(t *testing.T) {
docID := _testDocID
- cc := map[string]struct {
+ type tcase struct {
DB *DBMock
Applier *EditApplierMock
Branch xid.ID
- Touched []Touched
Applies int
- Err error
- }{
+
+ // Ops is how many operations the batch holds; 0 means one.
+ Ops int
+ Err error
+ }
+
+ cc := map[string]tcase{
"Error returned by db.FetchDocumentByBranchID": {
DB: &DBMock{
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*document.Document, error) {
@@ -867,56 +929,27 @@ func Test_input_ApplyEdit(t *testing.T) {
Applier: stubApplier(),
Err: fmt.Errorf("branch main: %w; write to one of draft (%s)", errBranchProtected, _stubBranchID),
},
- "Protected branch with no alternative says so": func() struct {
- DB *DBMock
- Applier *EditApplierMock
- Branch xid.ID
- Touched []Touched
- Applies int
- Err error
- } {
+ "Protected branch with no alternative says so": func() tcase {
db := protectedDocumentDB()
db.FetchDocumentBranchesFunc = stubBranches(true, true)
- return struct {
- DB *DBMock
- Applier *EditApplierMock
- Branch xid.ID
- Touched []Touched
- Applies int
- Err error
- }{
+ return tcase{
DB: db,
Applier: stubApplier(),
Err: fmt.Errorf("branch main: %w; the document has no unprotected branch to write to", errBranchProtected),
}
}(),
- "Applied to a named branch": func() struct {
- DB *DBMock
- Applier *EditApplierMock
- Branch xid.ID
- Touched []Touched
- Applies int
- Err error
- } {
+ "Applied to a named branch": func() tcase {
// the branch has to belong to the document being edited.
db := stubDocumentDB()
db.FetchDocumentByBranchIDFunc = func(_ context.Context, branchID xid.ID, orgID string) (*document.Document, error) {
return stubBranchDocument(docID, orgID, branchID, _stubBranchName), nil
}
- return struct {
- DB *DBMock
- Applier *EditApplierMock
- Branch xid.ID
- Touched []Touched
- Applies int
- Err error
- }{
+ return tcase{
DB: db,
Applier: stubApplier(),
Branch: _stubBranchID,
- Touched: []Touched{{DocumentID: docID, BranchID: _stubBranchID}},
Applies: 1,
}
}(),
@@ -930,25 +963,24 @@ func Test_input_ApplyEdit(t *testing.T) {
},
},
Applies: 1,
- Err: errors.New("applying edit: no block with uid a in this document; call get_document for the current uids"),
+ Err: fmt.Errorf("applying edit: %w", errors.New("no block with uid a in this document; call get_document for the current uids")),
},
- "Partial failure still reports the outcome": {
+ "Failed batch names the operation that failed": {
DB: stubDocumentDB(),
Applier: &EditApplierMock{
ApplyFunc: func(context.Context, xid.ID, xid.ID, []edit.Operation, string, bool) (edit.Result, error) {
return edit.Result{
- Applied: 1,
- Errors: []edit.OpError{{Index: 1, Message: "uid not found"}},
+ Errors: []edit.OpError{{Index: 1, Message: "uid not found"}},
}, nil
},
},
- Touched: []Touched{{DocumentID: docID, BranchID: _stubMainBranchID}},
+ Ops: 2,
Applies: 1,
+ Err: fmt.Errorf("applying edit: %w", errors.New("nothing was applied; operation 2: uid not found")),
},
"Applied": {
DB: stubDocumentDB(),
Applier: stubApplier(),
- Touched: []Touched{{DocumentID: docID, BranchID: _stubMainBranchID}},
Applies: 1,
},
}
@@ -957,18 +989,24 @@ func Test_input_ApplyEdit(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- inp := testInput(testDeps(c.DB, c.Applier, nil), NameInsertBlock, `{}`)
+ inp := testInput(testDeps(c.DB, c.Applier, nil), NameInsertBlocks, `{}`)
branch := c.Branch
if branch.IsNil() {
branch = _stubMainBranchID
}
- err := inp.ApplyEdit(docID, branch, []edit.Operation{edit.Delete("a")})
+ ops := make([]edit.Operation, 0, max(c.Ops, 1))
+ ops = append(ops, edit.Delete("a"))
+
+ for range c.Ops - 1 {
+ ops = append(ops, edit.Delete("b"))
+ }
+
+ err := inp.ApplyEdit(docID, branch, ops)
testutil.AssertEqualError(t, c.Err, err)
// an edit that never landed has changed nothing to report.
- assert.Equal(t, c.Touched, inp.touched)
// a refusal the tools own — a protected branch — stops
// before the batch is shipped, rather than letting it land
@@ -986,408 +1024,23 @@ func Test_input_ApplyEdit(t *testing.T) {
if c.Applier != nil {
for _, call := range c.Applier.ApplyCalls() {
assert.False(t, call.System)
- assert.Equal(t, inp.UserID(), call.UserID)
+ assert.Equal(t, inp.userID, call.UserID)
}
}
})
}
}
-// contentWithBlocks builds a DB whose branch content is the given
-// top-level blocks, for the validators that read the document to decide.
-func contentWithBlocks(blocks ...document.Block) *DBMock {
- return &DBMock{
- FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*document.Document, error) {
- return &document.Document{
- ID: _testDocID,
- Content: document.RootBlock{Content: blocks},
- }, nil
- },
- }
-}
-
-func Test_input_ValidateAttrUpdate(t *testing.T) {
- t.Parallel()
-
- // a heading at level 2, a metric inside its grid, and a task item —
- // a typed block with attr rules, a typed block with enum rules, and
- // a wrapper item the canonical model does not name.
- contentDB := contentWithBlocks(
- document.Block{
- Type: document.BlockNodeHeading,
- Attrs: document.Attributes{
- document.AttrUID: "head",
- document.AttrLevel: 2,
- },
- },
- document.Block{
- Type: document.BlockNodeMetricGrid,
- Attrs: document.Attributes{document.AttrUID: "grid"},
- Content: []document.Block{{
- Type: document.BlockNodeMetricBlock,
- Attrs: document.Attributes{
- document.AttrUID: "metric",
- document.AttrSimulationPreset: "cpu_usage",
- },
- }},
- },
- document.Block{
- Type: document.BlockNodeTaskList,
- Attrs: document.Attributes{document.AttrUID: "tasks"},
- Content: []document.Block{{
- Type: document.BlockNodeTaskItem,
- Attrs: document.Attributes{document.AttrUID: "task"},
- }},
- },
- document.Block{
- Type: document.BlockNodeTitledCodeBlock,
- Attrs: document.Attributes{document.AttrUID: "titled"},
- Content: []document.Block{
- {
- Type: document.BlockNodeCodeBlockTitle,
- Content: []document.Block{{Type: document.BlockNodeText, Text: "GET /x"}},
- },
- {Type: document.BlockNodeCodeBlock},
- },
- },
- )
-
- cc := map[string]struct {
- DB *DBMock
- UID string
- Attrs map[string]any
- Err error
- }{
- "Error returned by db.FetchDocumentByBranchID": {
- DB: &DBMock{
- FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*document.Document, error) {
- return nil, assert.AnError
- },
- },
- UID: "head",
- Attrs: map[string]any{document.AttrLevel: 2},
- Err: assert.AnError,
- },
- "Unresolved uid is left to the backend": {
- DB: contentDB,
- UID: "missing",
- Attrs: map[string]any{document.AttrLevel: 9},
- },
- "Wrapper item carries attrs the canonical model does not name": {
- DB: contentDB,
- UID: "task",
- Attrs: map[string]any{"checked": true},
- },
- "Allowed heading level": {
- DB: contentDB,
- UID: "head",
- Attrs: map[string]any{document.AttrLevel: 3},
- },
- "Heading level outside the range": {
- DB: contentDB,
- UID: "head",
- Attrs: map[string]any{document.AttrLevel: 9},
- Err: assert.AnError,
- },
- "Unrelated attr keeps the level already on the block": {
- DB: contentDB,
- UID: "head",
- Attrs: map[string]any{document.AttrIcon: "lucide:hash"},
- },
- "Unknown simulation preset": {
- DB: contentDB,
- UID: "metric",
- Attrs: map[string]any{document.AttrSimulationPreset: "solar_flares"},
- Err: assert.AnError,
- },
- "Known simulation preset": {
- DB: contentDB,
- UID: "metric",
- Attrs: map[string]any{document.AttrSimulationPreset: "error_rate"},
- },
- // the title sits on the title row, not on the block itself.
- "Titled code language keeps the title on its title row": {
- DB: contentDB,
- UID: "titled",
- Attrs: map[string]any{document.AttrLanguage: "go"},
- },
- "Titled code title emptied": {
- DB: contentDB,
- UID: "titled",
- Attrs: map[string]any{document.AttrTitle: " "},
- Err: assert.AnError,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- inp := testInput(testDeps(c.DB, nil, nil), NameUpdateBlockAttrs, `{}`)
-
- err := inp.ValidateAttrUpdate(_testDocID, _stubMainBranchID, c.UID, c.Attrs)
- testutil.AssertEqualError(t, c.Err, err)
- })
- }
-}
-
-func Test_input_ValidateMove(t *testing.T) {
- t.Parallel()
-
- // two lists, so a wrapper item has both a same-kind destination and
- // a root one to be tested against, plus a split_doc whose right side
- // holds the one block type that may not sit at the root.
- contentDB := contentWithBlocks(
- document.Block{
- Type: document.BlockNodeParagraph,
- Attrs: document.Attributes{document.AttrUID: "root-p"},
- },
- document.Block{
- Type: document.BlockNodeBulletList,
- Attrs: document.Attributes{document.AttrUID: "list-a"},
- Content: []document.Block{
- {
- Type: document.BlockNodeListItem,
- Attrs: document.Attributes{document.AttrUID: "item-a1"},
- },
- {
- Type: document.BlockNodeListItem,
- Attrs: document.Attributes{document.AttrUID: "item-a2"},
- },
- },
- },
- document.Block{
- Type: document.BlockNodeBulletList,
- Attrs: document.Attributes{document.AttrUID: "list-b"},
- Content: []document.Block{{
- Type: document.BlockNodeListItem,
- Attrs: document.Attributes{document.AttrUID: "item-b1"},
- }},
- },
- document.Block{
- Type: document.BlockNodeSplitDoc,
- Attrs: document.Attributes{document.AttrUID: "split"},
- Content: []document.Block{{
- Type: document.BlockNodeSplitDocRight,
- Content: []document.Block{{
- Type: document.BlockNodeTitledCodeBlock,
- Attrs: document.Attributes{document.AttrUID: "right-code"},
- }},
- }},
- },
- )
-
- cc := map[string]struct {
- DB *DBMock
- UID string
- Ref string
- Err error
- }{
- "Error returned by db.FetchDocumentByBranchID": {
- DB: &DBMock{
- FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*document.Document, error) {
- return nil, assert.AnError
- },
- },
- UID: "root-p",
- Ref: "list-a",
- Err: assert.AnError,
- },
- "Unresolved moved uid is left to the backend": {
- DB: contentDB,
- UID: "missing",
- Ref: "root-p",
- },
- "Unresolved reference is left to the backend": {
- DB: contentDB,
- UID: "root-p",
- Ref: "missing",
- },
- "Root block beside a root reference": {
- DB: contentDB,
- UID: "root-p",
- Ref: "list-a",
- },
- "Macro internal out to the document root is refused": {
- DB: contentDB,
- UID: "right-code",
- Ref: "root-p",
- Err: assert.AnError,
- },
- "Root block onto a split_doc right side is refused": {
- DB: contentDB,
- UID: "root-p",
- Ref: "right-code",
- Err: assert.AnError,
- },
- "List item reordered within its own list": {
- DB: contentDB,
- UID: "item-a1",
- Ref: "item-a2",
- },
- "List item moved into another list of the same kind": {
- DB: contentDB,
- UID: "item-a1",
- Ref: "item-b1",
- },
- "List item out to the document root is refused": {
- DB: contentDB,
- UID: "item-a1",
- Ref: "root-p",
- Err: assert.AnError,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- inp := testInput(testDeps(c.DB, nil, nil), NameMoveBlock, `{}`)
-
- err := inp.ValidateMove(_testDocID, _stubMainBranchID, c.UID, c.Ref)
- testutil.AssertEqualError(t, c.Err, err)
- })
- }
-}
-
-func Test_input_ValidatePlacement(t *testing.T) {
- t.Parallel()
-
- rootBlock := block.Block{Type: block.BlockParagraph, Text: "hi"}
- macroBlock := block.Block{
- Type: block.BlockTitledCode,
- Text: "code",
- Attrs: map[string]any{"title": "Request"},
- }
-
- // a document with a reference at every container kind the tools can
- // land a block next to: the root ("root-p"), a callout item
- // ("callout-p") and a split_doc right side ("right-code").
- contentDB := &DBMock{
- FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*document.Document, error) {
- return &document.Document{
- ID: _testDocID,
- Content: document.RootBlock{
- Content: []document.Block{
- {
- Type: document.BlockNodeParagraph,
- Attrs: document.Attributes{document.AttrUID: "root-p"},
- },
- {
- Type: document.BlockNodeCalloutBlock,
- Attrs: document.Attributes{document.AttrUID: "callout"},
- Content: []document.Block{{
- Type: document.BlockNodeParagraph,
- Attrs: document.Attributes{document.AttrUID: "callout-p"},
- }},
- },
- {
- Type: document.BlockNodeSplitDoc,
- Attrs: document.Attributes{document.AttrUID: "split"},
- Content: []document.Block{{
- Type: document.BlockNodeSplitDocRight,
- Content: []document.Block{{
- Type: document.BlockNodeTitledCodeBlock,
- Attrs: document.Attributes{document.AttrUID: "right-code"},
- }},
- }},
- },
- },
- },
- }, nil
- },
- }
-
- cc := map[string]struct {
- DB *DBMock
- Ref string
- Block block.Block
- Err error
- }{
- "Error returned by db.FetchDocumentByBranchID": {
- DB: &DBMock{
- FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*document.Document, error) {
- return nil, assert.AnError
- },
- },
- Ref: "root-p",
- Block: macroBlock,
- Err: assert.AnError,
- },
- "Invalid block with an unresolved reference": {
- DB: contentDB,
- Ref: "missing",
- Block: block.Block{Type: "nonsense"},
- Err: assert.AnError,
- },
- "Valid block with an unresolved reference is left to the backend": {
- DB: contentDB,
- Ref: "missing",
- Block: macroBlock,
- },
- "Invalid block beside a resolved reference": {
- DB: contentDB,
- Ref: "root-p",
- Block: block.Block{Type: "nonsense"},
- Err: assert.AnError,
- },
- "Root block beside a root reference is allowed": {
- DB: contentDB,
- Ref: "root-p",
- Block: rootBlock,
- },
- "Macro internal beside a root reference is refused": {
- DB: contentDB,
- Ref: "root-p",
- Block: macroBlock,
- Err: assert.AnError,
- },
- "Root block inside a callout is allowed": {
- DB: contentDB,
- Ref: "callout-p",
- Block: rootBlock,
- },
- "Macro internal inside a callout is refused": {
- DB: contentDB,
- Ref: "callout-p",
- Block: macroBlock,
- Err: assert.AnError,
- },
- "Macro internal on a split_doc right side is allowed": {
- DB: contentDB,
- Ref: "right-code",
- Block: macroBlock,
- },
- "Root block on a split_doc right side is refused": {
- DB: contentDB,
- Ref: "right-code",
- Block: rootBlock,
- Err: assert.AnError,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- inp := testInput(testDeps(c.DB, nil, nil), NameInsertBlock, `{}`)
-
- err := inp.ValidatePlacement(_testDocID, _stubMainBranchID, c.Ref, c.Block)
- testutil.AssertEqualError(t, c.Err, err)
- })
- }
-}
-
-func Test_input_NotifyTreeChange(t *testing.T) {
+func Test_input_notifyTreeChange(t *testing.T) {
t.Parallel()
parentID := null.ValueFrom(xid.New())
// a session without a notifier silently no-ops.
- testInput(testDeps(nil, nil, nil), NameCreateDocument, `{}`).NotifyTreeChange(parentID)
+ testInput(testDeps(nil, nil, nil), NameCreateDocument, `{}`).notifyTreeChange(parentID)
tree := &TreeNotifierMock{}
- testInput(testDeps(nil, nil, tree), NameCreateDocument, `{}`).NotifyTreeChange(parentID)
+ testInput(testDeps(nil, nil, tree), NameCreateDocument, `{}`).notifyTreeChange(parentID)
ff := tree.NotifyTreeChangeCalls()
require.Len(t, ff, 1)
@@ -1395,37 +1048,6 @@ func Test_input_NotifyTreeChange(t *testing.T) {
assert.Equal(t, parentID, ff[0].ParentID)
}
-func Test_input_NotifyTreeChangeForDocument(t *testing.T) {
- t.Parallel()
-
- cc := map[string]struct {
- DB *DBMock
- Notify int
- }{
- "Error returned by db.FetchDocument": {
- DB: &DBMock{
- FetchDocumentFunc: func(context.Context, xid.ID, string, string) (*document.Document, error) {
- return nil, assert.AnError
- },
- },
- },
- "Parent is announced": {DB: stubDocumentDB(), Notify: 1},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- tree := &TreeNotifierMock{}
-
- testInput(testDeps(c.DB, nil, tree), NameUpdateDocument, `{}`).
- NotifyTreeChangeForDocument(_testDocID)
-
- assert.Len(t, tree.NotifyTreeChangeCalls(), c.Notify)
- })
- }
-}
-
func Test_input_Warn(t *testing.T) {
t.Parallel()
@@ -1444,36 +1066,98 @@ func Test_input_Warn(t *testing.T) {
func Test_input_CheckDataSources(t *testing.T) {
t.Parallel()
- d := dataSourceDeps(t, datasource.TypePrometheus, nil)
- inp := testInput(d, NameInsertBlock, "")
+ metric := func(id string) document.Block {
+ return document.Block{Type: document.BlockNodeMetricBlock, Attrs: document.Attributes{document.AttrDataSourceID: id}}
+ }
- require.NoError(t, inp.CheckDataSources(nil))
- require.NoError(t, inp.CheckDataSources([]string{_testDataSourceID.String()}))
+ grid := func(blocks ...document.Block) []document.Block {
+ return []document.Block{{Type: document.BlockNodeMetricGrid, Content: blocks}}
+ }
- // an id that is not an xid and one the organisation owns nothing for
- // are both refused, named by the attribute they arrived in.
- for _, id := range []string{"wibble", xid.New().String()} {
- err := inp.CheckDataSources([]string{_testDataSourceID.String(), id})
- require.Error(t, err, "id %q should be refused", id)
- assert.Contains(t, err.Error(), "dataSourceId")
+ unknown := xid.New().String()
+
+ cc := map[string]struct {
+ Blocks []document.Block
+ Err error
+ }{
+ "No blocks": {},
+ "Owned data source nested in a grid": {
+ Blocks: grid(metric(_testDataSourceID.String())),
+ },
+ "Empty id is skipped": {
+ Blocks: grid(metric("")),
+ },
+ "Id that is not an xid": {
+ Blocks: grid(metric(_testDataSourceID.String()), metric("wibble")),
+ Err: assert.AnError,
+ },
+ "Id the organisation owns nothing for": {
+ Blocks: grid(metric(_testDataSourceID.String()), metric(unknown)),
+ Err: fmt.Errorf("metric dataSourceId %q: %w", unknown, errUnknownDataSource),
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ inp := testInput(dataSourceDeps(t, datasource.TypePrometheus, nil), NameInsertBlocks, "")
+
+ testutil.AssertEqualError(t, c.Err, inp.CheckDataSources(c.Blocks))
+ })
}
}
func Test_input_FetchDataSource(t *testing.T) {
t.Parallel()
- d := dataSourceDeps(t, datasource.TypePrometheus, nil)
- inp := testInput(d, NameQueryPrometheus, "")
+ failing := &DBMock{
+ FetchDataSourceFunc: func(context.Context, xid.ID, string) (*datasource.DataSource, error) {
+ return nil, assert.AnError
+ },
+ }
- ds, err := inp.FetchDataSource(_testDataSourceID)
- require.NoError(t, err)
- require.NotNil(t, ds)
- assert.Equal(t, "prod", ds.Name)
+ cc := map[string]struct {
+ DB *DBMock
+ ID xid.ID
+ Err error
+ }{
+ "Error returned by db.FetchDataSource": {
+ DB: failing,
+ ID: _testDataSourceID,
+ Err: fmt.Errorf("fetching data source: %w", assert.AnError),
+ },
+ // an id the organisation owns nothing for comes back as a
+ // failure rather than as a zero data source.
+ "Unknown data source": {
+ ID: xid.New(),
+ Err: errUnknownDataSource,
+ },
+ "Successful fetch": {
+ ID: _testDataSourceID,
+ },
+ }
- // an id the organisation owns nothing for comes back as a failure
- // rather than as a zero data source.
- _, err = inp.FetchDataSource(xid.New())
- require.Error(t, err)
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ d := dataSourceDeps(t, datasource.TypePrometheus, nil)
+ if c.DB != nil {
+ d.db = c.DB
+ }
+
+ ds, err := testInput(d, NameQueryDataSource, "").FetchDataSource(c.ID)
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ require.NotNil(t, ds)
+ assert.Equal(t, "prod", ds.Name)
+ })
+ }
}
func Test_input_FetchDataSources(t *testing.T) {
@@ -1491,7 +1175,7 @@ func Test_input_DataSourceRunner(t *testing.T) {
t.Parallel()
runner := &datasourceMock.Runner{}
- inp := testInput(dataSourceDeps(t, datasource.TypePrometheus, runner), NameQueryPrometheus, "")
+ inp := testInput(dataSourceDeps(t, datasource.TypePrometheus, runner), NameQueryDataSource, "")
got, err := inp.DataSourceRunner(_testDataSourceID)
require.NoError(t, err)
@@ -1544,7 +1228,7 @@ func Test_input_FetchDocumentBlock(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- inp := testInput(testDeps(c.DB, nil, nil), NameUpdateBlockText, `{}`)
+ inp := testInput(testDeps(c.DB, nil, nil), NameReplaceBlocks, `{}`)
b, err := inp.FetchDocumentBlock(_testDocID, c.Branch, c.UID)
testutil.AssertEqualError(t, c.Err, err)
@@ -1553,7 +1237,8 @@ func Test_input_FetchDocumentBlock(t *testing.T) {
return
}
- assert.Equal(t, c.Result, b.Text)
+ require.NotEmpty(t, b.Content)
+ assert.Equal(t, c.Result, b.Content[0].Text)
})
}
}
@@ -1659,7 +1344,9 @@ func Test_input_FetchBranch(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- doc, err := testInput(testDeps(c.DB, nil, nil), NameGetDocument, `{}`).FetchBranch(_testDocID, c.Branch)
+ inp := testInput(testDeps(c.DB, nil, nil), NameGetDocument, `{}`)
+
+ doc, err := inp.FetchBranch(_testDocID, c.Branch)
testutil.AssertEqualError(t, c.Err, err)
if err != nil {
@@ -1668,6 +1355,16 @@ func Test_input_FetchBranch(t *testing.T) {
assert.Equal(t, c.Result, doc.BranchName)
assert.Equal(t, c.Branch, doc.BranchID)
+
+ // the steps of one call share the fetch, and asking for the
+ // branch as another document's is still refused.
+ again, err := inp.FetchBranch(_testDocID, c.Branch)
+ require.NoError(t, err)
+ assert.Same(t, doc, again)
+ assert.Len(t, c.DB.FetchDocumentByBranchIDCalls(), 1)
+
+ _, err = inp.FetchBranch(xid.New(), c.Branch)
+ require.Error(t, err)
})
}
}
@@ -1736,55 +1433,6 @@ func Test_input_FetchDocumentBranches(t *testing.T) {
}
}
-func Test_input_FetchDocumentContent(t *testing.T) {
- t.Parallel()
-
- cc := map[string]struct {
- DB *DBMock
- Branch xid.ID
- Result string
- Err error
- }{
- "Error returned by db.FetchDocumentByBranchID": {
- DB: stubContentDB(assert.AnError),
- Branch: _stubMainBranchID,
- Err: assert.AnError,
- },
- "Unknown branch": {
- DB: stubContentDB(nil),
- Branch: _unknownBranchID,
- Err: assert.AnError,
- },
- "Default branch content": {
- DB: stubContentDB(nil),
- Branch: _stubMainBranchID,
- Result: "hello",
- },
- "Another branch's content": {
- DB: stubContentDB(nil),
- Branch: _stubBranchID,
- Result: "hello",
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- content, err := testInput(testDeps(c.DB, nil, nil), NameGetDocument, `{}`).FetchDocumentContent(_testDocID, c.Branch)
- testutil.AssertEqualError(t, c.Err, err)
-
- if err != nil {
- return
- }
-
- require.NotEmpty(t, content.Content.Content)
- assert.Equal(t, c.Result, content.Content.Content[0].Text)
- assert.Equal(t, _testDocID, content.DocumentID)
- })
- }
-}
-
func Test_input_protectedBranch(t *testing.T) {
t.Parallel()
@@ -1797,12 +1445,12 @@ func Test_input_protectedBranch(t *testing.T) {
},
}
- err := testInput(testDeps(db, nil, nil), NameInsertBlock, `{}`).protectedBranch(doc)
+ err := testInput(testDeps(db, nil, nil), NameInsertBlocks, `{}`).protectedBranch(doc)
require.Error(t, err)
assert.Contains(t, err.Error(), errBranchProtected.Error())
// success: the unprotected branches are offered.
- err = testInput(testDeps(protectedDocumentDB(), nil, nil), NameInsertBlock, `{}`).protectedBranch(doc)
+ err = testInput(testDeps(protectedDocumentDB(), nil, nil), NameInsertBlocks, `{}`).protectedBranch(doc)
assert.Equal(t, fmt.Errorf("branch main: %w; write to one of draft (%s)", errBranchProtected, _stubBranchID), err)
}
@@ -1815,9 +1463,8 @@ func Test_branchLabels(t *testing.T) {
{BranchID: draftID, BranchName: "draft"},
}
- assert.Equal(t, []string{"main (" + mainID.String() + ")", "draft (" + draftID.String() + ")"}, branchLabels(branches, nil))
- assert.Equal(t, []string{"draft (" + draftID.String() + ")"}, branchLabels(branches, func(b document.BranchSummary) bool { return !b.Protected }))
- assert.Empty(t, branchLabels(nil, nil))
+ assert.Equal(t, "main ("+mainID.String()+"), draft ("+draftID.String()+")", branchLabels(branches))
+ assert.Empty(t, branchLabels(nil))
}
func Test_input_FetchTagTree(t *testing.T) {
@@ -2013,7 +1660,6 @@ func Test_input_AssignTag(t *testing.T) {
DB *DBMock
Branch xid.ID
Assigns int
- Touched []Touched
Err error
}{
"Unknown branch is refused before the write": {
@@ -2051,7 +1697,6 @@ func Test_input_AssignTag(t *testing.T) {
DB: stubDocumentDB(),
Branch: _stubBranchID,
Assigns: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
}
@@ -2059,7 +1704,7 @@ func Test_input_AssignTag(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- inp := testInput(testDeps(c.DB, nil, nil), NameAssignTag, `{}`)
+ inp := testInput(testDeps(c.DB, nil, nil), NameSetTagAssignment, `{}`)
err := inp.AssignTag(_testDocID, c.Branch, _testTagID)
testutil.AssertEqualError(t, c.Err, err)
@@ -2073,8 +1718,6 @@ func Test_input_AssignTag(t *testing.T) {
assert.Equal(t, c.Branch, ff[0].BranchID)
assert.Equal(t, _testTagID, ff[0].TagID)
}
-
- assert.Equal(t, c.Touched, inp.touched)
})
}
}
@@ -2087,7 +1730,6 @@ func Test_input_UnassignTag(t *testing.T) {
Branch xid.ID
Tag xid.ID
Unassigns int
- Touched []Touched
Err error
}{
"Unknown branch is refused before the write": {
@@ -2123,7 +1765,6 @@ func Test_input_UnassignTag(t *testing.T) {
Branch: _stubBranchID,
Tag: _testTagID,
Unassigns: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
}
@@ -2131,7 +1772,7 @@ func Test_input_UnassignTag(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- inp := testInput(testDeps(c.DB, nil, nil), NameUnassignTag, `{}`)
+ inp := testInput(testDeps(c.DB, nil, nil), NameSetTagAssignment, `{}`)
err := inp.UnassignTag(_testDocID, c.Branch, c.Tag)
testutil.AssertEqualError(t, c.Err, err)
@@ -2145,8 +1786,6 @@ func Test_input_UnassignTag(t *testing.T) {
assert.Equal(t, c.Branch, ff[0].BranchID)
assert.Equal(t, c.Tag, ff[0].TagID)
}
-
- assert.Equal(t, c.Touched, inp.touched)
})
}
}
@@ -2215,7 +1854,7 @@ func Test_input_MoveTag(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- err := testInput(testDeps(c.DB, nil, nil), NameMoveTag, `{}`).MoveTag(c.Tag, c.SortIndex)
+ err := testInput(testDeps(c.DB, nil, nil), NameUpdateTag, `{}`).MoveTag(c.Tag, c.SortIndex)
testutil.AssertEqualError(t, c.Err, err)
ff := c.DB.UpdateTagTreeCalls()
@@ -2238,28 +1877,28 @@ func Test_input_MoveTag(t *testing.T) {
}
}
-func Test_input_NotifyTagTreeChange(t *testing.T) {
+func Test_input_notifyTagTreeChange(t *testing.T) {
t.Parallel()
// a session without a notifier silently no-ops.
- testInput(testDeps(nil, nil, nil), NameCreateTag, `{}`).NotifyTagTreeChange()
+ testInput(testDeps(nil, nil, nil), NameCreateTag, `{}`).notifyTagTreeChange()
d, tags := tagDeps(nil)
- testInput(d, NameCreateTag, `{}`).NotifyTagTreeChange()
+ testInput(d, NameCreateTag, `{}`).notifyTagTreeChange()
ff := tags.NotifyTreeChangeCalls()
require.Len(t, ff, 1)
assert.Equal(t, "org", ff[0].OrganizationID)
}
-func Test_input_NotifyBranchTagsChange(t *testing.T) {
+func Test_input_notifyBranchTagsChange(t *testing.T) {
t.Parallel()
// a session without a notifier silently no-ops.
- testInput(testDeps(nil, nil, nil), NameAssignTag, `{}`).NotifyBranchTagsChange(_testDocID, _stubBranchID)
+ testInput(testDeps(nil, nil, nil), NameSetTagAssignment, `{}`).notifyBranchTagsChange(_testDocID, _stubBranchID)
d, tags := tagDeps(nil)
- testInput(d, NameAssignTag, `{}`).NotifyBranchTagsChange(_testDocID, _stubBranchID)
+ testInput(d, NameSetTagAssignment, `{}`).notifyBranchTagsChange(_testDocID, _stubBranchID)
ff := tags.NotifyBranchTagsChangeCalls()
require.Len(t, ff, 1)
@@ -2394,7 +2033,7 @@ func Test_input_FetchHook(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- hk, err := testInput(testDeps(c.DB, nil, nil), NameResetHook, `{}`).FetchHook(_testDocID, c.ID)
+ hk, err := testInput(testDeps(c.DB, nil, nil), NameUpdateHook, `{}`).FetchHook(_testDocID, c.ID)
testutil.AssertEqualError(t, c.Err, err)
ff := c.DB.FetchDocumentHookCalls()
@@ -2412,16 +2051,6 @@ func Test_input_FetchHook(t *testing.T) {
}
}
-func Test_input_unknownHook(t *testing.T) {
- t.Parallel()
-
- err := testInput(testDeps(nil, nil, nil), NameResetHook, `{}`).unknownHook(_testDocID, _unknownHookID)
-
- // the message names the document the call addressed, so a hook that
- // exists elsewhere is not mistaken for a typo.
- assert.Equal(t, fmt.Errorf("hook %s on document %s: %w", _unknownHookID, _testDocID, errUnknownHook), err)
-}
-
func Test_input_CreateHook(t *testing.T) {
t.Parallel()
@@ -2445,7 +2074,6 @@ func Test_input_CreateHook(t *testing.T) {
Type hook.Type
Settings processor.Settings
Inserts int
- Touched []Touched
Err error
}{
"Unknown branch is refused before anything is created": {
@@ -2494,7 +2122,6 @@ func Test_input_CreateHook(t *testing.T) {
Type: hook.TypeScheduledReminder,
Settings: scheduled,
Inserts: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
"Created on a block": {
DB: stubHookDB(),
@@ -2503,7 +2130,6 @@ func Test_input_CreateHook(t *testing.T) {
Type: hook.TypeScheduledReminder,
Settings: scheduled,
Inserts: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
}
@@ -2520,11 +2146,9 @@ func Test_input_CreateHook(t *testing.T) {
ff := d.hookMan.(*HookManagerMock).CreateHookCalls()
require.Len(t, ff, c.Inserts)
- assert.Equal(t, c.Touched, inp.touched)
-
// the write is credited to the user the assistant acts for.
for _, call := range ff {
- assert.Equal(t, inp.UserID(), call.UpdatedBy)
+ assert.Equal(t, inp.userID, call.UpdatedBy)
}
if err != nil {
@@ -2558,7 +2182,6 @@ func Test_input_UpdateHook(t *testing.T) {
Man *HookManagerMock
Settings processor.Settings
Updates int
- Touched []Touched
Err error
}{
"Error returned by hookMan.UpdateHook": {
@@ -2572,7 +2195,6 @@ func Test_input_UpdateHook(t *testing.T) {
DB: stubHookDB(),
Settings: processor.Settings(`{"scale":"linear","duration":"custom","schedule":"2031-01-01T00:00:00Z"}`),
Updates: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
}
@@ -2589,11 +2211,10 @@ func Test_input_UpdateHook(t *testing.T) {
ff := d.hookMan.(*HookManagerMock).UpdateHookCalls()
require.Len(t, ff, c.Updates)
- assert.Equal(t, c.Touched, inp.touched)
// the write is credited to the user the assistant acts for.
for _, call := range ff {
- assert.Equal(t, inp.UserID(), call.UpdatedBy)
+ assert.Equal(t, inp.userID, call.UpdatedBy)
}
if err != nil {
@@ -2623,7 +2244,6 @@ func Test_input_ResetHook(t *testing.T) {
Man *HookManagerMock
Hook *hook.Hook
Updates int
- Touched []Touched
Err error
}{
"Error returned by hookMan.ResetHook": {
@@ -2637,7 +2257,6 @@ func Test_input_ResetHook(t *testing.T) {
DB: stubHookDB(),
Hook: stubHook(),
Updates: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
}
@@ -2646,14 +2265,13 @@ func Test_input_ResetHook(t *testing.T) {
t.Parallel()
d := hookDeps(c.DB, c.Man)
- inp := testInput(d, NameResetHook, `{}`)
+ inp := testInput(d, NameUpdateHook, `{}`)
err := inp.ResetHook(c.Hook)
testutil.AssertEqualError(t, c.Err, err)
ff := d.hookMan.(*HookManagerMock).ResetHookCalls()
require.Len(t, ff, c.Updates)
- assert.Equal(t, c.Touched, inp.touched)
if err != nil {
return
@@ -2680,7 +2298,6 @@ func Test_input_DeleteHook(t *testing.T) {
Man *HookManagerMock
Hook *hook.Hook
Deletes int
- Touched []Touched
Err error
}{
"Error returned by hookMan.DeleteHook": {
@@ -2694,7 +2311,6 @@ func Test_input_DeleteHook(t *testing.T) {
DB: stubHookDB(),
Hook: stubHook(),
Deletes: 1,
- Touched: []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}},
},
}
@@ -2710,11 +2326,10 @@ func Test_input_DeleteHook(t *testing.T) {
ff := d.hookMan.(*HookManagerMock).DeleteHookCalls()
require.Len(t, ff, c.Deletes)
- assert.Equal(t, c.Touched, inp.touched)
// the write is credited to the user the assistant acts for.
for _, call := range ff {
- assert.Equal(t, inp.UserID(), call.UpdatedBy)
+ assert.Equal(t, inp.userID, call.UpdatedBy)
}
if err != nil {
diff --git a/server/core/internal/assistant/tools/schema.go b/server/core/internal/assistant/tools/schema.go
deleted file mode 100644
index 9c72a1ff..00000000
--- a/server/core/internal/assistant/tools/schema.go
+++ /dev/null
@@ -1,315 +0,0 @@
-package tools
-
-import (
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
- "github.com/oxynote/oxynote/server/core/internal/document"
-)
-
-// _blockSchema is the block argument's schema for the write tools: one
-// variant per type the assistant can write, each naming its type as a
-// constant and taking only its own fields. file is absent, since it is
-// only ever created by uploading in the editor.
-var _blockSchema = blockSchema()
-
-// blockSchema builds the block argument's schema, one variant per
-// writable type in documentation order.
-func blockSchema() map[string]any {
- types := block.Types()
- variants := make([]map[string]any, 0, len(types))
-
- for _, t := range types {
- if v, ok := blockVariant(block.Type(t)); ok {
- variants = append(variants, v)
- }
- }
-
- return map[string]any{
- "description": "A canonical block. Its type decides which fields it takes.",
- "anyOf": variants,
- }
-}
-
-// blockVariant builds the schema of one block type: its type as a
-// constant, its uid, the fields it takes and which of them are
-// required, and no other field. The second result is false for a type
-// the assistant cannot write.
-func blockVariant(t block.Type) (map[string]any, bool) {
- var (
- props map[string]any
- required []string
- )
-
- switch t {
- case block.BlockParagraph:
- props = map[string]any{
- "text": map[string]any{"type": "string", "description": "Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one."},
- "children": map[string]any{
- "type": "array",
- "description": "Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.",
- "items": map[string]any{"type": "object"},
- },
- }
- case block.BlockHeading:
- props = map[string]any{
- "text": map[string]any{"type": "string", "description": "Plain text; a heading takes no formatting, so no markdown is parsed."},
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrLevel: map[string]any{
- "type": "integer",
- "description": "The heading level.",
- "enum": []int{1, 2, 3},
- },
- },
- "required": []string{document.AttrLevel},
- },
- }
- required = []string{"attrs"}
- case block.BlockBlockquote:
- props = map[string]any{
- "text": map[string]any{"type": "string", "description": "Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both."},
- "items": map[string]any{
- "type": "array",
- "description": "The blocks it quotes, of any type legal at the document root. Use text or items, not both.",
- "items": map[string]any{"type": "object"},
- },
- }
- case block.BlockBulletList, block.BlockOrderedList:
- props = map[string]any{
- "items": map[string]any{
- "type": "array",
- "description": "The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.",
- "items": map[string]any{"type": "object"},
- "minItems": 1,
- },
- }
- required = []string{"items"}
- case block.BlockTaskList:
- props = map[string]any{
- "task_items": map[string]any{
- "type": "array",
- "description": "The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.",
- "minItems": 1,
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "checked": map[string]any{"type": "boolean"},
- "block": map[string]any{"type": "object"},
- },
- "required": []string{"block"},
- },
- },
- }
- required = []string{"task_items"}
- case block.BlockCallout:
- props = map[string]any{
- "text": map[string]any{"type": "string", "description": "Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both."},
- "items": map[string]any{
- "type": "array",
- "description": "The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.",
- "items": map[string]any{"type": "object"},
- },
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrIcon: map[string]any{"type": "string", "description": "Iconify identifier; defaults to lucide:text."},
- },
- },
- }
- case block.BlockCode:
- props = map[string]any{
- "text": map[string]any{"type": "string", "description": "Raw source; no markdown."},
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrLanguage: map[string]any{"type": "string", "description": "Optional; leave empty rather than guess."},
- },
- },
- }
- case block.BlockTitledCode:
- props = map[string]any{
- "text": map[string]any{"type": "string", "description": "Raw source; no markdown."},
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrTitle: map[string]any{"type": "string", "description": "Plain-text label naming what the code is. For an endpoint, the method and route, such as POST /v1/assets/{id}/due-date/preview."},
- document.AttrLanguage: map[string]any{"type": "string", "description": "Optional; leave empty rather than guess."},
- },
- "required": []string{document.AttrTitle},
- },
- }
- required = []string{"attrs"}
- case block.BlockMermaid:
- props = map[string]any{"text": map[string]any{"type": "string", "description": "Raw Mermaid source; no markdown. Mermaid ends a gantt task name or a timeline period at its first colon, so write any colon inside one as #58; (13#58;00)."}}
- case block.BlockHorizontalRule:
- props = map[string]any{}
- case block.BlockImage:
- props = map[string]any{
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrSrc: map[string]any{"type": "string", "description": "The image address."},
- document.AttrAlt: map[string]any{"type": "string", "description": "Alternative text."},
- document.AttrTitle: map[string]any{"type": "string", "description": "Caption."},
- document.AttrWidth: map[string]any{"type": "integer", "description": "Display width in pixels."},
- },
- "required": []string{document.AttrSrc},
- },
- }
- required = []string{"attrs"}
- case block.BlockFigma:
- props = map[string]any{
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrSrc: map[string]any{"type": "string", "description": "The Figma frame address."},
- document.AttrWidth: map[string]any{"type": "integer", "description": "Display width in pixels."},
- document.AttrHeight: map[string]any{"type": "integer", "description": "Display height in pixels."},
- },
- "required": []string{document.AttrSrc},
- },
- }
- required = []string{"attrs"}
- case block.BlockMetric:
- props = map[string]any{
- "attrs": map[string]any{
- "type": "object",
- "description": "The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.",
- "properties": metricAttrProps(),
- },
- }
- case block.BlockMetricGrid:
- props = map[string]any{
- "items": map[string]any{
- "type": "array",
- "description": "The metric blocks it holds.",
- "items": map[string]any{"type": "object"},
- "minItems": 1,
- },
- }
- required = []string{"items"}
- case block.BlockSplitDoc:
- props = map[string]any{
- "left": map[string]any{
- "type": "array",
- "description": "The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.",
- "items": map[string]any{"type": "object"},
- "minItems": 1,
- },
- "right": map[string]any{
- "type": "array",
- "description": "The example side. Holds titled_code, metric or mermaid only, and is the one place titled_code is legal.",
- "items": map[string]any{"type": "object"},
- "minItems": 1,
- },
- "attrs": map[string]any{
- "type": "object",
- "properties": map[string]any{
- document.AttrInversed: map[string]any{"type": "boolean", "description": "Flips the two sides."},
- },
- },
- }
- required = []string{"left", "right"}
- case block.BlockParamList:
- props = map[string]any{
- "header": map[string]any{"type": "string", "description": "The section header, as plain text."},
- "params": map[string]any{
- "type": "array",
- "description": "The rows.",
- "minItems": 1,
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "name": map[string]any{"type": "string", "description": "The parameter name, as plain text."},
- "type": map[string]any{"type": "string", "description": "The parameter type, as plain text."},
- "description": map[string]any{"type": "string", "description": "The description, in minimal markdown."},
- },
- "required": []string{"name"},
- },
- },
- }
- required = []string{"header", "params"}
- default:
- return nil, false
- }
-
- props["type"] = map[string]any{"const": string(t)}
- props["uid"] = map[string]any{"type": "string", "description": "Leave unset; generated server-side."}
-
- return map[string]any{
- "title": string(t),
- "type": "object",
- "properties": props,
- "required": append([]string{"type"}, required...),
- "additionalProperties": false,
- }, true
-}
-
-// _enumAttrs are the metric attributes whose legal values are a fixed
-// set, listed in the order the block model introduces them.
-var _enumAttrs = []struct {
- // Name is the attribute's key inside attrs.
- Name string
-
- // Desc says what the attribute sets.
- Desc string
-}{
- {document.AttrVisualizationType, "The chart kind."},
- {document.AttrTimeRange, "The window queried."},
- {document.AttrRefreshInterval, "How often the chart re-queries."},
- {document.AttrUnitType, "The unit values are read in. With custom, put the label in unitCustom."},
- {document.AttrWidth, "The chart's size."},
- {document.AttrSimulationPreset, "Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query."},
-}
-
-// metricAttrProps builds the metric attrs sub-schema. An attribute
-// with a fixed value set is published as an enum, which lets a client
-// reject a bad value itself rather than learning the values from prose
-// it may never have been given.
-func metricAttrProps() map[string]any {
- enums := block.MetricEnums()
- out := make(map[string]any)
-
- for _, a := range _enumAttrs {
- out[a.Name] = map[string]any{
- "type": "string",
- "description": a.Desc,
- "enum": enums[a.Name],
- }
- }
-
- out[document.AttrDataSourceID] = map[string]any{"type": "string", "description": "The data source the chart queries, an id from list_data_sources. Required for the block to render."}
- out[document.AttrQueries] = map[string]any{
- "type": "array",
- "description": "The queries charted, in PromQL or SQL as the data source takes. A SQL chart selects a time column aliased \"time\" plus one or more numeric columns, and may use the $__ macros.",
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "name": map[string]any{"type": "string", "description": "The query's name."},
- "query": map[string]any{"type": "string", "description": "The query itself."},
- "legendFormat": map[string]any{"type": "string", "description": "Optional series label template; may be empty."},
- },
- "required": []string{"name", "query"},
- },
- }
- out[document.AttrTitle] = map[string]any{"type": "string", "description": "Optional chart title."}
- out[document.AttrUnitCustom] = map[string]any{"type": "string", "description": "The unit label, read when unitType is custom."}
- out[document.AttrDecimals] = map[string]any{"type": "number", "description": "Optional number of decimals shown."}
- out[document.AttrThresholds] = map[string]any{
- "type": "array",
- "description": "Optional threshold rows, each colouring the values from its value up.",
- "items": map[string]any{
- "type": "object",
- "properties": map[string]any{
- "value": map[string]any{"type": "number"},
- "label": map[string]any{"type": "string"},
- "color": map[string]any{"type": "string"},
- },
- },
- }
- out[document.AttrBaseThresholdColor] = map[string]any{"type": "string", "description": "Optional colour of the values below the first threshold."}
- out[document.AttrAxisBoundsMin] = map[string]any{"type": "number", "description": "Optional fixed lower bound of the value axis."}
- out[document.AttrAxisBoundsMax] = map[string]any{"type": "number", "description": "Optional fixed upper bound of the value axis."}
-
- return out
-}
diff --git a/server/core/internal/assistant/tools/schema_test.go b/server/core/internal/assistant/tools/schema_test.go
deleted file mode 100644
index a83852bd..00000000
--- a/server/core/internal/assistant/tools/schema_test.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package tools
-
-import (
- "slices"
- "testing"
-
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
- "github.com/oxynote/oxynote/server/core/internal/document"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func Test_blockSchema(t *testing.T) {
- t.Parallel()
-
- variants, ok := _blockSchema["anyOf"].([]map[string]any)
- require.True(t, ok)
-
- // one variant per canonical type in documentation order, less file,
- // which the assistant never writes.
- want := slices.DeleteFunc(block.Types(), func(t string) bool { return t == string(block.BlockFile) })
- require.Len(t, variants, len(want))
-
- for i, v := range variants {
- tp := want[i]
-
- assert.Equal(t, tp, v["title"])
- assert.Equal(t, false, v["additionalProperties"])
-
- props, pok := v["properties"].(map[string]any)
- require.True(t, pok, tp)
- assert.Equal(t, map[string]any{"const": tp}, props["type"], tp)
- assert.Contains(t, props, "uid", tp)
-
- // everything required is a field the variant publishes.
- required, rok := v["required"].([]string)
- require.True(t, rok, tp)
- assert.Equal(t, "type", required[0], tp)
-
- for _, key := range required {
- assert.Contains(t, props, key, tp)
- }
- }
-}
-
-func Test_blockVariant(t *testing.T) {
- t.Parallel()
-
- _, ok := blockVariant(block.BlockFile)
- assert.False(t, ok)
-
- // heading level is the one non-metric enum, and the only integer
- // one: a client sending "2" rather than 2 is sending the wrong type.
- heading, ok := blockVariant(block.BlockHeading)
- require.True(t, ok)
- assert.Equal(t, []string{"type", "attrs"}, heading["required"])
-
- attrs := heading["properties"].(map[string]any)["attrs"].(map[string]any)
- assert.Equal(t, []string{document.AttrLevel}, attrs["required"])
-
- level := attrs["properties"].(map[string]any)[document.AttrLevel].(map[string]any)
- assert.Equal(t, "integer", level["type"])
- assert.Equal(t, []int{1, 2, 3}, level["enum"])
-
- // the atoms that point somewhere require their src.
- for _, tp := range []block.Type{block.BlockImage, block.BlockFigma} {
- v, vok := blockVariant(tp)
- require.True(t, vok)
- assert.Equal(t, []string{"type", "attrs"}, v["required"], tp)
- assert.Equal(t, []string{document.AttrSrc}, v["properties"].(map[string]any)["attrs"].(map[string]any)["required"], tp)
- }
-
- // the metric enums are reachable from the metric variant, which is
- // what a client actually reads.
- metric, ok := blockVariant(block.BlockMetric)
- require.True(t, ok)
- assert.Equal(t, metricAttrProps(), metric["properties"].(map[string]any)["attrs"].(map[string]any)["properties"])
-}
-
-func Test_metricAttrProps(t *testing.T) {
- t.Parallel()
-
- got := metricAttrProps()
-
- // every metric enum is published, width included: the metric attrs
- // are their own schema, so the name no longer has to mean the same
- // thing as an image's pixel width.
- enums := block.MetricEnums()
-
- for attr, values := range enums {
- prop, pok := got[attr].(map[string]any)
- require.True(t, pok, "attr %q is missing from the schema", attr)
-
- assert.Equal(t, "string", prop["type"])
- assert.Equal(t, values, prop["enum"], "attr %q publishes the wrong values", attr)
- assert.NotEmpty(t, prop["description"], "attr %q has no description", attr)
- }
-
- // the attrs without a fixed value set are published too, typed as
- // the validator reads them, so a client sees every attr it may send.
- free := map[string]string{
- document.AttrDataSourceID: "string",
- document.AttrQueries: "array",
- document.AttrTitle: "string",
- document.AttrUnitCustom: "string",
- document.AttrDecimals: "number",
- document.AttrThresholds: "array",
- document.AttrBaseThresholdColor: "string",
- document.AttrAxisBoundsMin: "number",
- document.AttrAxisBoundsMax: "number",
- }
-
- require.Len(t, got, len(enums)+len(free))
-
- for attr, typ := range free {
- prop, pok := got[attr].(map[string]any)
- require.True(t, pok, "attr %q is missing from the schema", attr)
-
- assert.Equal(t, typ, prop["type"], "attr %q has the wrong type", attr)
- assert.NotEmpty(t, prop["description"], "attr %q has no description", attr)
- }
-}
diff --git a/server/core/internal/assistant/tools/search.go b/server/core/internal/assistant/tools/search.go
index 40381077..895eef77 100644
--- a/server/core/internal/assistant/tools/search.go
+++ b/server/core/internal/assistant/tools/search.go
@@ -44,21 +44,18 @@ func (a searchDocumentsArgs) Validate() error {
}
// searchDocuments runs a full-text search across the organisation.
-type searchDocuments struct {
- plainSummary
- plainTraits
-}
+type searchDocuments struct{}
// Info returns the tool's model-facing description.
func (searchDocuments) Info() Info {
return Info{
Name: NameSearchDocuments,
- Description: "Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.",
+ Description: "Full-text search for blocks whose text matches the query, across every branch of every document. Returns {documents: [{document_id, document_name, branch_id, branch_name, default, hits: [{block_uid, text}]}]}, best match first. A block a fork copied unchanged is found on every branch holding it. A hit is the innermost block holding the text; get_document with its block_uid reads the element holding it. Use list_documents to find a document by title.",
Properties: map[string]any{
- "query": map[string]any{"type": "string", "description": "The full-text search query (typo-tolerant, matches block text)."},
+ "query": map[string]any{"type": "string", "description": "The text to search for; matching is typo-tolerant."},
"limit": map[string]any{
"type": "integer",
- "description": "Maximum number of hits to return. Defaults to 20; cap is 50.",
+ "description": "Optional. The most hits to return: 20 by default, 50 at most.",
},
},
Required: []string{"query"},
@@ -66,18 +63,18 @@ func (searchDocuments) Info() Info {
}
// Title announces the query being run.
-func (searchDocuments) Title(inp DescribeInput) (string, error) {
+func (searchDocuments) Title(inp DescribeInput) string {
var in searchDocumentsArgs
if err := inp.Decode(&in); err != nil {
- return "", err
+ return ""
}
- return fmt.Sprintf("Searching for %q", in.Query), nil
+ return fmt.Sprintf("Searching for %q", in.Query)
}
// Execute searches the index and decorates hits with document names.
-func (searchDocuments) Execute(inp Input) (string, error) {
+func (searchDocuments) Execute(inp *input) (string, error) {
var in searchDocumentsArgs
if err := inp.Decode(&in); err != nil {
@@ -93,9 +90,9 @@ func (searchDocuments) Execute(inp Input) (string, error) {
limit = _searchLimitMax
}
- blocks, err := inp.SearchBlocks(in.Query, limit)
+ blocks, err := inp.search.SearchDocumentBlocks(inp.Context(), inp.orgID, in.Query, limit)
if err != nil {
- return "", fmt.Errorf("search_documents: search: %w", err)
+ return "", fmt.Errorf("search: %w", err)
}
// the index stores block text keyed by (branch, block uid) but not
@@ -121,34 +118,47 @@ func (searchDocuments) Execute(inp Input) (string, error) {
}
}
- hits := make([]searchHit, 0, len(blocks))
+ // hits of one branch are listed under it once, in the order its
+ // first hit ranked.
+ var (
+ out = searchResult{Documents: []searchDocument{}}
+ index = map[xid.ID]int{}
+ )
for _, b := range blocks {
- hits = append(hits, searchHit{
- DocumentID: b.DocumentID,
- DocumentName: names[b.DocumentID].DocumentName,
- BranchID: b.BranchID,
- BranchName: b.BranchName,
- Default: b.BranchDefault,
- BlockUID: strings.TrimPrefix(b.ID, b.BranchID.String()+"-"),
- Text: b.Text,
+ at, seen := index[b.BranchID]
+ if !seen {
+ at = len(out.Documents)
+ index[b.BranchID] = at
+
+ out.Documents = append(out.Documents, searchDocument{
+ DocumentID: b.DocumentID,
+ DocumentName: names[b.DocumentID].DocumentName,
+ BranchID: b.BranchID,
+ BranchName: b.BranchName,
+ Default: b.BranchDefault,
+ })
+ }
+
+ out.Documents[at].Hits = append(out.Documents[at].Hits, searchHit{
+ BlockUID: strings.TrimPrefix(b.ID, b.BranchID.String()+"-"),
+ Text: b.Text,
})
}
- return result(searchResult{
- Hits: hits,
- })
+ return result(out)
}
// searchResult is what search_documents returns.
type searchResult struct {
- // Hits are the matching blocks, in relevance order.
- Hits []searchHit `json:"hits"`
+ // Documents are the document branches holding a match, in the order
+ // of their best hit.
+ Documents []searchDocument `json:"documents"`
}
-// searchHit is one search_documents result row.
-type searchHit struct {
- // DocumentID is the document containing the matching block.
+// searchDocument is one document branch holding search hits.
+type searchDocument struct {
+ // DocumentID is the document containing the matching blocks.
DocumentID xid.ID `json:"document_id"`
// DocumentName is the document's display name, when resolvable from
@@ -156,7 +166,7 @@ type searchHit struct {
// and the search.
DocumentName string `json:"document_name,omitempty"`
- // BranchID is the branch the hit was indexed from, which is what a
+ // BranchID is the branch the hits were indexed from, which is what a
// follow-up read takes.
BranchID xid.ID `json:"branch_id"`
@@ -166,8 +176,14 @@ type searchHit struct {
// Default reports whether that branch is the document's default.
Default bool `json:"default"`
+ // Hits are the matching blocks of the branch, in relevance order.
+ Hits []searchHit `json:"hits"`
+}
+
+// searchHit is one matching block.
+type searchHit struct {
// BlockUID is the matching block's uid attribute, usable with
- // read_block and the edit tools.
+ // get_document and the edit tools.
BlockUID string `json:"block_uid"`
// Text is the block's indexed text.
diff --git a/server/core/internal/assistant/tools/search_test.go b/server/core/internal/assistant/tools/search_test.go
index e0df1bd7..5363b5c8 100644
--- a/server/core/internal/assistant/tools/search_test.go
+++ b/server/core/internal/assistant/tools/search_test.go
@@ -12,7 +12,6 @@ import (
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/rs/xid"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func Test_searchDocumentsArgs_Validate(t *testing.T) {
@@ -28,36 +27,29 @@ func Test_searchDocuments_Info(t *testing.T) {
info := searchDocuments{}.Info()
+ assert.Equal(t, Traits{}, info.Traits)
+
assert.Equal(t, NameSearchDocuments, info.Name)
assert.Equal(t, []string{"query"}, info.Required)
assert.Contains(t, info.Properties, "limit")
}
-func Test_searchDocuments_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{}, searchDocuments{}.Traits())
-}
-
func Test_searchDocuments_Title(t *testing.T) {
t.Parallel()
d := testDeps(nil, nil, nil)
- got, err := searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{"query":"rate limit"}`))
- require.NoError(t, err)
- assert.Equal(t, `Searching for "rate limit"`, got)
-
- _, err = searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{}`))
- require.Error(t, err)
+ assert.Equal(t, `Searching for "rate limit"`, searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{"query":"rate limit"}`)))
+ assert.Empty(t, searchDocuments{}.Title(testInput(d, NameSearchDocuments, `{}`)))
}
func Test_searchDocuments_Execute(t *testing.T) {
t.Parallel()
- docID, branchID := xid.New(), xid.New()
+ docID, branchID, mainID := xid.New(), xid.New(), xid.New()
branchFields := `"branch_id":"` + branchID.String() + `","branch_name":"draft","default":false,`
+ hit := `"hits":[{"block_uid":"b1","text":"rate limiting"}]`
hitSearcher := func(limit *int) *SearcherMock {
return &SearcherMock{
@@ -124,7 +116,7 @@ func Test_searchDocuments_Execute(t *testing.T) {
DB: &DBMock{},
Args: `{"query":"rate limit"}`,
Limit: _searchLimitDefault,
- Result: `{"hits":[]}`,
+ Result: `{"documents":[]}`,
},
"Names are joined in": {
Searcher: hitSearcher(nil),
@@ -133,10 +125,9 @@ func Test_searchDocuments_Execute(t *testing.T) {
return document.Summaries{{ID: docID, DocumentName: "Runbook"}}, nil
},
},
- Args: `{"query":"rate limit"}`,
- Limit: _searchLimitDefault,
- Result: `{"hits":[{"document_id":"` + docID.String() + `","document_name":"Runbook",` +
- branchFields + `"block_uid":"b1","text":"rate limiting"}]}`,
+ Args: `{"query":"rate limit"}`,
+ Limit: _searchLimitDefault,
+ Result: `{"documents":[{"document_id":"` + docID.String() + `","document_name":"Runbook",` + branchFields + hit + `}]}`,
},
"Losing the names does not fail the search": {
Searcher: hitSearcher(nil),
@@ -147,7 +138,7 @@ func Test_searchDocuments_Execute(t *testing.T) {
},
Args: `{"query":"rate limit"}`,
Limit: _searchLimitDefault,
- Result: `{"hits":[{"document_id":"` + docID.String() + `",` + branchFields + `"block_uid":"b1","text":"rate limiting"}]}`,
+ Result: `{"documents":[{"document_id":"` + docID.String() + `",` + branchFields + hit + `}]}`,
Logged: "cannot fetch the document tree for search hit names",
},
"Requested limit is honoured": {
@@ -155,16 +146,33 @@ func Test_searchDocuments_Execute(t *testing.T) {
DB: &DBMock{},
Args: `{"query":"rate limit","limit":5}`,
Limit: 5,
- Result: `{"hits":[{"document_id":"` + docID.String() + `",` + branchFields + `"block_uid":"b1",` +
- `"text":"rate limiting"}]}`,
+ Result: `{"documents":[{"document_id":"` + docID.String() + `",` + branchFields + hit + `}]}`,
+ },
+ // hits of one branch sit under it once, in the order its best hit
+ // ranked; another branch of the same document is its own entry.
+ "Hits are grouped by branch": {
+ Searcher: &SearcherMock{
+ SearchDocumentBlocksFunc: func(context.Context, string, string, int) ([]search.Block, error) {
+ return []search.Block{
+ {DocumentID: docID, BranchID: branchID, BranchName: "draft", ID: branchID.String() + "-b1", Text: "one"},
+ {DocumentID: docID, BranchID: mainID, BranchName: "main", BranchDefault: true, ID: mainID.String() + "-b2", Text: "two"},
+ {DocumentID: docID, BranchID: branchID, BranchName: "draft", ID: branchID.String() + "-b3", Text: "three"},
+ }, nil
+ },
+ },
+ DB: &DBMock{},
+ Args: `{"query":"rate limit"}`,
+ Limit: _searchLimitDefault,
+ Result: `{"documents":[` +
+ `{"document_id":"` + docID.String() + `",` + branchFields + `"hits":[{"block_uid":"b1","text":"one"},{"block_uid":"b3","text":"three"}]},` +
+ `{"document_id":"` + docID.String() + `","branch_id":"` + mainID.String() + `","branch_name":"main","default":true,"hits":[{"block_uid":"b2","text":"two"}]}]}`,
},
"Oversized limit is clipped": {
Searcher: hitSearcher(nil),
DB: &DBMock{},
Args: `{"query":"rate limit","limit":5000}`,
Limit: _searchLimitMax,
- Result: `{"hits":[{"document_id":"` + docID.String() + `",` + branchFields + `"block_uid":"b1",` +
- `"text":"rate limiting"}]}`,
+ Result: `{"documents":[{"document_id":"` + docID.String() + `",` + branchFields + hit + `}]}`,
},
}
diff --git a/server/core/internal/assistant/tools/tag.go b/server/core/internal/assistant/tools/tag.go
index f92f3647..246e68e7 100644
--- a/server/core/internal/assistant/tools/tag.go
+++ b/server/core/internal/assistant/tools/tag.go
@@ -2,8 +2,8 @@ package tools
import (
"cmp"
+ "errors"
"fmt"
- "strings"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/tag"
@@ -20,23 +20,19 @@ func (listTagsArgs) Validate() error {
// listTags returns the organisation's tags with the documents carrying
// them.
-type listTags struct {
- plainSummary
- plainTraits
- plainTitle
-}
+type listTags struct{}
// Info returns the tool's model-facing description.
func (listTags) Info() Info {
return Info{
Name: NameListTags,
- Description: "List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where color is a palette name, or unknown for a tag whose stored colour is outside the palette and wants recolouring with update_tag, and documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.",
+ Description: "List the organisation's tags in sidebar order as [{id, name, color, hidden, documents}]. color is a palette name, or unknown for a colour outside the palette that update_tag can fix. documents are the documents whose default branch carries the tag; a tag on another branch shows only on get_document for that branch. hidden is whether the current user keeps the tag out of their sidebar, and does not stop it being assigned.",
Properties: map[string]any{},
}
}
// Execute lists every tag with the documents carrying it.
-func (listTags) Execute(inp Input) (string, error) {
+func (listTags) Execute(inp *input) (string, error) {
var in listTagsArgs
if err := inp.Decode(&in); err != nil {
@@ -45,7 +41,7 @@ func (listTags) Execute(inp Input) (string, error) {
tree, err := inp.FetchTagTree()
if err != nil {
- return "", fmt.Errorf("list_tags: fetch tags: %w", err)
+ return "", fmt.Errorf("fetch tags: %w", err)
}
out := tagListResult{Tags: make([]tagEntry, 0, len(tree))}
@@ -133,22 +129,14 @@ func (a createTagArgs) Validate() error {
return errRequired("color")
}
- inp, err := a.input()
- if err != nil {
- return err
- }
-
- return inp.Validate()
+ return a.input().Validate()
}
-// input returns the arguments as the domain's create input.
-func (a createTagArgs) input() (tag.CreateInput, error) {
- hex := tag.ColorHex(a.ColorName)
- if hex == "" {
- return tag.CreateInput{}, tag.ErrInvalidTagColor
- }
-
- return tag.CreateInput{TagName: a.Name, Color: hex}, nil
+// input returns the arguments as the domain's create input. A colour
+// outside the palette maps to no colour, which the input's Validate
+// refuses.
+func (a createTagArgs) input() tag.CreateInput {
+ return tag.CreateInput{TagName: a.Name, Color: tag.ColorHex(a.ColorName)}
}
// createTag creates a new tag in the organisation.
@@ -158,31 +146,16 @@ type createTag struct{}
func (createTag) Info() Info {
return Info{
Name: NameCreateTag,
- Description: "Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color one of the palette names the schema lists; a colour outside the palette or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.",
+ Traits: Traits{Write: true},
+ Description: "Create a tag and return {tag_id}. The name has to be unused in the organisation. The tag lands last in the sidebar order; update_tag moves it and set_tag_assignment puts it on a document.",
Properties: map[string]any{
- "name": map[string]any{"type": "string", "description": "Display name for the new tag; unique within the organisation."},
- "color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "The tag's colour, one of the palette names."},
+ "name": map[string]any{"type": "string", "description": "The display name."},
+ "color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "The colour."},
},
Required: []string{"name", "color"},
}
}
-// Traits reports a write.
-func (createTag) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces the tag being created.
-func (createTag) Title(inp DescribeInput) (string, error) {
- var in createTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- return "Creating tag " + in.Name, nil
-}
-
// Summary describes the tag the model wants to create.
func (createTag) Summary(inp DescribeInput) (ActionSummary, error) {
var in createTagArgs
@@ -198,26 +171,19 @@ func (createTag) Summary(inp DescribeInput) (ActionSummary, error) {
}
// Execute creates the tag and refreshes the tag tree.
-func (createTag) Execute(inp Input) (string, error) {
+func (createTag) Execute(inp *input) (string, error) {
var in createTagArgs
if err := inp.Decode(&in); err != nil {
return "", err
}
- ci, err := in.input()
- if err != nil {
- return "", err
- }
-
- t := tag.NewTag(ci, inp.OrganizationID(), inp.UserID())
+ t := tag.NewTag(in.input(), inp.orgID, inp.userID)
if err := inp.CreateTag(t); err != nil {
- return "", fmt.Errorf("create_tag: %w", err)
+ return "", err
}
- inp.NotifyTagTreeChange()
-
return result(createdTagResult{TagID: t.ID})
}
@@ -238,6 +204,10 @@ type updateTagArgs struct {
// ColorName is the new colour as a palette name; empty leaves it
// unchanged.
ColorName string `json:"color"`
+
+ // SortIndex is the 0-based position the tag moves to; absent leaves
+ // it where it is.
+ SortIndex null.Value[int] `json:"sort_index"`
}
// Validate checks the arguments name a tag and change something.
@@ -246,17 +216,27 @@ func (a updateTagArgs) Validate() error {
return errRequired("tag_id")
}
- inp, err := a.input()
- if err != nil {
- return err
+ if !a.renames() {
+ if !a.SortIndex.Valid {
+ return errors.New("give at least one of name, color or sort_index")
+ }
+
+ return nil
}
- return inp.Validate()
+ return a.input().Validate()
+}
+
+// renames reports whether the call changes the name or the colour, which
+// is the tag's own update rather than a move.
+func (a updateTagArgs) renames() bool {
+ return a.Name != "" || a.ColorName != ""
}
// input returns the arguments as the domain's update input, with only
-// the fields that were given set.
-func (a updateTagArgs) input() (tag.UpdateInput, error) {
+// the fields that were given set. A colour outside the palette maps to
+// no colour, which the input's Validate refuses.
+func (a updateTagArgs) input() tag.UpdateInput {
var inp tag.UpdateInput
if a.Name != "" {
@@ -264,15 +244,10 @@ func (a updateTagArgs) input() (tag.UpdateInput, error) {
}
if a.ColorName != "" {
- hex := tag.ColorHex(a.ColorName)
- if hex == "" {
- return tag.UpdateInput{}, tag.ErrInvalidTagColor
- }
-
- inp.Color = null.StringFrom(hex)
+ inp.Color = null.StringFrom(tag.ColorHex(a.ColorName))
}
- return inp, nil
+ return inp
}
// changes lists the requested changes in the words the confirm card
@@ -288,47 +263,32 @@ func (a updateTagArgs) changes(currentName string) []string {
out = append(out, "set the colour to "+a.ColorName)
}
+ if a.SortIndex.Valid {
+ out = append(out, fmt.Sprintf("move it to position %d", a.SortIndex.V+1))
+ }
+
return out
}
-// updateTag renames or recolours a tag, or both in one call.
+// updateTag renames, recolours or moves a tag, any of them in one call.
type updateTag struct{}
// Info returns the tool's model-facing description.
func (updateTag) Info() Info {
return Info{
Name: NameUpdateTag,
- Description: "Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color one of the palette names the schema lists; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.",
+ Traits: Traits{Write: true},
+ Description: "Rename, recolour or move a tag in the sidebar order; give only what changes, and a call changing nothing is refused. A new name has to be unused in the organisation. Returns {tag_id} with what changed. set_tag_assignment changes which documents carry it.",
Properties: map[string]any{
- "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
- "name": map[string]any{"type": "string", "description": "Optional. The new display name; omit to keep the current one."},
- "color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "Optional. The new colour, one of the palette names; omit to keep the current one."},
+ "tag_id": map[string]any{"type": "string", "description": _tagIDDescription},
+ "name": map[string]any{"type": "string", "description": "Optional. The new display name."},
+ "color": map[string]any{"type": "string", "enum": tag.ColorNames(), "description": "Optional. The new colour."},
+ "sort_index": map[string]any{"type": "integer", "description": "Optional. The 0-based position in list_tags order; 0 is first."},
},
Required: []string{"tag_id"},
}
}
-// Traits reports a write.
-func (updateTag) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which tag is being updated.
-func (updateTag) Title(inp DescribeInput) (string, error) {
- var in updateTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- tg, err := inp.FetchTag(in.TagID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch tag: %w", NameUpdateTag, err)
- }
-
- return "Updating tag " + tg.TagName, nil
-}
-
// Summary lists exactly the changes the model asked for.
func (updateTag) Summary(inp DescribeInput) (ActionSummary, error) {
var in updateTagArgs
@@ -339,38 +299,55 @@ func (updateTag) Summary(inp DescribeInput) (ActionSummary, error) {
tg, err := inp.FetchTag(in.TagID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameUpdateTag, err)
+ return ActionSummary{}, fmt.Errorf("fetch tag: %w", err)
}
- changes := in.changes(tg.TagName)
- summary := strings.Join(changes, " and ")
-
return ActionSummary{
Tool: NameUpdateTag,
- Summary: strings.ToUpper(summary[:1]) + summary[1:],
+ Summary: sentence(in.changes(tg.TagName)),
}, nil
}
// Execute applies the change and refreshes the tag tree.
-func (updateTag) Execute(inp Input) (string, error) {
+func (updateTag) Execute(inp *input) (string, error) {
var in updateTagArgs
if err := inp.Decode(&in); err != nil {
return "", err
}
- ui, err := in.input()
- if err != nil {
- return "", err
+ // the rename and the move are two writes. The move is tried on the
+ // current order first, so a position it would refuse fails the call
+ // before the rename is saved.
+ if in.renames() && in.SortIndex.Valid {
+ tree, err := inp.FetchTagTree()
+ if err != nil {
+ return "", fmt.Errorf("fetching tags: %w", err)
+ }
+
+ if _, err := tree.Swap(in.TagID, in.SortIndex.V); err != nil {
+ return "", err
+ }
}
- if err = inp.UpdateTag(in.TagID, ui); err != nil {
- return "", fmt.Errorf("update_tag: %w", err)
+ if in.renames() {
+ if err := inp.UpdateTag(in.TagID, in.input()); err != nil {
+ return "", err
+ }
}
- inp.NotifyTagTreeChange()
+ if in.SortIndex.Valid {
+ if err := inp.MoveTag(in.TagID, in.SortIndex.V); err != nil {
+ return "", err
+ }
+ }
- return result(updatedTagResult(in))
+ out := updatedTagResult{TagID: in.TagID, Name: in.Name, ColorName: in.ColorName}
+ if in.SortIndex.Valid {
+ out.SortIndex = &in.SortIndex.V
+ }
+
+ return result(out)
}
// updatedTagResult is what update_tag returns: the tag and the fields
@@ -384,6 +361,9 @@ type updatedTagResult struct {
// ColorName is the new colour's palette name, when one was set.
ColorName string `json:"color,omitempty"`
+
+ // SortIndex is the tag's new 0-based position, when it was moved.
+ SortIndex *int `json:"sort_index,omitempty"`
}
// deleteTagArgs is what delete_tag is called with.
@@ -408,36 +388,15 @@ type deleteTag struct{}
func (deleteTag) Info() Info {
return Info{
Name: NameDeleteTag,
- Description: "Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.",
+ Traits: Traits{Write: true, Destructive: true},
+ Description: "Delete a tag. Every document loses it, on every branch, and it cannot be restored; set_tag_assignment takes it off one document instead. Returns {tag_id, deleted}.",
Properties: map[string]any{
- "tag_id": map[string]any{"type": "string", "description": "The id of the tag to delete."},
+ "tag_id": map[string]any{"type": "string", "description": _tagIDDescription},
},
Required: []string{"tag_id"},
}
}
-// Traits reports a destructive write, which stays outside any "approve
-// all" answer.
-func (deleteTag) Traits() Traits {
- return Traits{Write: true, Destructive: true}
-}
-
-// Title announces which tag is being deleted.
-func (deleteTag) Title(inp DescribeInput) (string, error) {
- var in deleteTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- tg, err := inp.FetchTag(in.TagID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch tag: %w", NameDeleteTag, err)
- }
-
- return "Deleting tag " + tg.TagName, nil
-}
-
// Summary describes the tag the model wants to delete.
func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
var in deleteTagArgs
@@ -448,7 +407,7 @@ func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
tg, err := inp.FetchTag(in.TagID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameDeleteTag, err)
+ return ActionSummary{}, fmt.Errorf("fetch tag: %w", err)
}
summary := "Delete tag " + tg.TagName
@@ -457,7 +416,7 @@ func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
// only the tag would have the user approve a change they were never
// shown.
if n := len(tg.Documents); n > 0 {
- summary += fmt.Sprintf(" and take it off the %s carrying it", pluralPages(n))
+ summary += fmt.Sprintf(" and take it off the %s carrying it", countPhrase(n, "page"))
}
return ActionSummary{
@@ -467,7 +426,7 @@ func (deleteTag) Summary(inp DescribeInput) (ActionSummary, error) {
}
// Execute deletes the tag and refreshes the tag tree.
-func (deleteTag) Execute(inp Input) (string, error) {
+func (deleteTag) Execute(inp *input) (string, error) {
var in deleteTagArgs
if err := inp.Decode(&in); err != nil {
@@ -475,11 +434,9 @@ func (deleteTag) Execute(inp Input) (string, error) {
}
if err := inp.DeleteTag(in.TagID); err != nil {
- return "", fmt.Errorf("delete_tag: %w", err)
+ return "", err
}
- inp.NotifyTagTreeChange()
-
return result(deletedTagResult{TagID: in.TagID, Deleted: true})
}
@@ -493,18 +450,23 @@ type deletedTagResult struct {
Deleted bool `json:"deleted"`
}
-// branchTagArgs is what assign_tag and unassign_tag are called with: a
-// branch and a tag.
-type branchTagArgs struct {
+// setTagAssignmentArgs is what set_tag_assignment is called with: a
+// branch, a tag, and whether the branch carries it.
+type setTagAssignmentArgs struct {
docTarget
// TagID names the tag.
TagID xid.ID `json:"tag_id"`
+
+ // Assigned says whether the branch carries the tag after the call.
+ // Required, since false is an answer.
+ Assigned null.Value[bool] `json:"assigned"`
}
-// Validate checks the arguments name a branch and a tag.
-func (a branchTagArgs) Validate() error {
- if err := a.validate(); err != nil {
+// Validate checks the arguments name a branch and a tag and say which
+// way the assignment goes.
+func (a setTagAssignmentArgs) Validate() error {
+ if err := a.docTarget.Validate(); err != nil {
return err
}
@@ -512,50 +474,35 @@ func (a branchTagArgs) Validate() error {
return errRequired("tag_id")
}
+ if !a.Assigned.Valid {
+ return errRequired("assigned")
+ }
+
return nil
}
-// assignTag puts a tag on a document branch.
-type assignTag struct{}
+// setTagAssignment puts a tag on a document branch or takes it off.
+type setTagAssignment struct{}
// Info returns the tool's model-facing description.
-func (assignTag) Info() Info {
+func (setTagAssignment) Info() Info {
return Info{
- Name: NameAssignTag,
- Description: "Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.",
+ Name: NameSetTagAssignment,
+ Traits: Traits{Write: true},
+ Description: "Put a tag on one branch of a document, or take it off. The document is listed under the tag in the sidebar while its default branch carries it. Setting what is already the case succeeds and changes nothing; delete_tag removes a tag everywhere. Returns {document_id, branch_id, tag_id, assigned}.",
Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
- "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
+ "document_id": map[string]any{"type": "string", "description": _documentIDDescription},
+ "branch_id": map[string]any{"type": "string", "description": _branchIDDescription},
+ "tag_id": map[string]any{"type": "string", "description": _tagIDDescription},
+ "assigned": map[string]any{"type": "boolean", "description": "true puts the tag on the branch, false takes it off."},
},
- Required: []string{"document_id", "branch_id", "tag_id"},
+ Required: []string{"document_id", "branch_id", "tag_id", "assigned"},
}
}
-// Traits reports a write.
-func (assignTag) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which document is being tagged.
-func (assignTag) Title(inp DescribeInput) (string, error) {
- var in branchTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameAssignTag, err)
- }
-
- return "Tagging " + doc.Title(), nil
-}
-
-// Summary names the tag and the document it goes on.
-func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) {
- var in branchTagArgs
+// Summary names the tag and the document it goes on or comes off.
+func (setTagAssignment) Summary(inp DescribeInput) (ActionSummary, error) {
+ var in setTagAssignmentArgs
if err := inp.Decode(&in); err != nil {
return ActionSummary{}, err
@@ -563,124 +510,53 @@ func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) {
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameAssignTag, err)
+ return ActionSummary{}, fmt.Errorf("fetch document: %w", err)
}
tg, err := inp.FetchTag(in.TagID)
if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameAssignTag, err)
+ return ActionSummary{}, fmt.Errorf("fetch tag: %w", err)
+ }
+
+ summary := fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title())
+ if in.Assigned.V {
+ summary = fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title())
}
return ActionSummary{
- Tool: NameAssignTag,
+ Tool: NameSetTagAssignment,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
- Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title()),
+ Summary: summary,
}, nil
}
-// Execute assigns the tag and refreshes the tag tree.
-func (assignTag) Execute(inp Input) (string, error) {
- var in branchTagArgs
+// Execute assigns or unassigns the tag and refreshes the tag tree.
+func (setTagAssignment) Execute(inp *input) (string, error) {
+ var in setTagAssignmentArgs
if err := inp.Decode(&in); err != nil {
return "", err
}
- if err := inp.AssignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
- return "", fmt.Errorf("assign_tag: %w", err)
- }
-
- inp.NotifyTagTreeChange()
- inp.NotifyBranchTagsChange(in.DocumentID, in.BranchID)
-
- return result(branchTagResult{DocumentID: in.DocumentID, BranchID: in.BranchID, TagID: in.TagID})
-}
-
-// unassignTag takes a tag off a document branch.
-type unassignTag struct{}
-
-// Info returns the tool's model-facing description.
-func (unassignTag) Info() Info {
- return Info{
- Name: NameUnassignTag,
- Description: "Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.",
- Properties: map[string]any{
- "document_id": map[string]any{"type": "string", "description": "The document id."},
- "branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."},
- "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
- },
- Required: []string{"document_id", "branch_id", "tag_id"},
- }
-}
-
-// Traits reports a write.
-func (unassignTag) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which document is being untagged.
-func (unassignTag) Title(inp DescribeInput) (string, error) {
- var in branchTagArgs
-
- if err := inp.Decode(&in); err != nil {
+ if in.Assigned.V {
+ if err := inp.AssignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
+ return "", err
+ }
+ } else if err := inp.UnassignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
return "", err
}
- doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err)
- }
-
- return "Untagging " + doc.Title(), nil
+ return result(tagAssignmentResult{
+ DocumentID: in.DocumentID,
+ BranchID: in.BranchID,
+ TagID: in.TagID,
+ Assigned: in.Assigned.V,
+ })
}
-// Summary names the tag and the document it comes off.
-func (unassignTag) Summary(inp DescribeInput) (ActionSummary, error) {
- var in branchTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return ActionSummary{}, err
- }
-
- doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
- if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err)
- }
-
- tg, err := inp.FetchTag(in.TagID)
- if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameUnassignTag, err)
- }
-
- return ActionSummary{
- Tool: NameUnassignTag,
- DocumentID: doc.ID,
- DocumentName: doc.DocumentName,
- Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title()),
- }, nil
-}
-
-// Execute unassigns the tag and refreshes the tag tree.
-func (unassignTag) Execute(inp Input) (string, error) {
- var in branchTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- if err := inp.UnassignTag(in.DocumentID, in.BranchID, in.TagID); err != nil {
- return "", fmt.Errorf("unassign_tag: %w", err)
- }
-
- inp.NotifyTagTreeChange()
- inp.NotifyBranchTagsChange(in.DocumentID, in.BranchID)
-
- return result(branchTagResult{DocumentID: in.DocumentID, BranchID: in.BranchID, TagID: in.TagID})
-}
-
-// branchTagResult is what assign_tag and unassign_tag return.
-type branchTagResult struct {
+// tagAssignmentResult is what set_tag_assignment returns.
+type tagAssignmentResult struct {
// DocumentID is the document whose branch changed.
DocumentID xid.ID `json:"document_id"`
@@ -689,109 +565,7 @@ type branchTagResult struct {
// TagID is the tag.
TagID xid.ID `json:"tag_id"`
-}
-
-// moveTagArgs is what move_tag is called with.
-type moveTagArgs struct {
- // TagID names the tag being moved.
- TagID xid.ID `json:"tag_id"`
-
- // SortIndex is the 0-based position the tag should end up at.
- // Required, since the first position is a valid answer.
- SortIndex null.Value[int] `json:"sort_index"`
-}
-
-// Validate checks the arguments are complete.
-func (a moveTagArgs) Validate() error {
- if a.TagID.IsNil() {
- return errRequired("tag_id")
- }
-
- if !a.SortIndex.Valid {
- return errRequired("sort_index")
- }
-
- return nil
-}
-
-// moveTag changes a tag's position in the sidebar order.
-type moveTag struct{}
-
-// Info returns the tool's model-facing description.
-func (moveTag) Info() Info {
- return Info{
- Name: NameMoveTag,
- Description: "Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.",
- Properties: map[string]any{
- "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."},
- "sort_index": map[string]any{"type": "integer", "description": "The 0-based position the tag should end up at among the organisation's tags."},
- },
- Required: []string{"tag_id", "sort_index"},
- }
-}
-
-// Traits reports a write.
-func (moveTag) Traits() Traits {
- return Traits{Write: true}
-}
-
-// Title announces which tag is being moved.
-func (moveTag) Title(inp DescribeInput) (string, error) {
- var in moveTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- tg, err := inp.FetchTag(in.TagID)
- if err != nil {
- return "", fmt.Errorf("%s: fetch tag: %w", NameMoveTag, err)
- }
-
- return "Moving tag " + tg.TagName, nil
-}
-
-// Summary names the tag and where it goes.
-func (moveTag) Summary(inp DescribeInput) (ActionSummary, error) {
- var in moveTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return ActionSummary{}, err
- }
-
- tg, err := inp.FetchTag(in.TagID)
- if err != nil {
- return ActionSummary{}, fmt.Errorf("%s: fetch tag: %w", NameMoveTag, err)
- }
-
- return ActionSummary{
- Tool: NameMoveTag,
- Summary: fmt.Sprintf("Move tag %s to position %d", tg.TagName, in.SortIndex.V+1),
- }, nil
-}
-
-// Execute moves the tag and refreshes the tag tree.
-func (moveTag) Execute(inp Input) (string, error) {
- var in moveTagArgs
-
- if err := inp.Decode(&in); err != nil {
- return "", err
- }
-
- if err := inp.MoveTag(in.TagID, in.SortIndex.V); err != nil {
- return "", fmt.Errorf("move_tag: %w", err)
- }
-
- inp.NotifyTagTreeChange()
-
- return result(movedTagResult{TagID: in.TagID, SortIndex: in.SortIndex.V})
-}
-
-// movedTagResult is what move_tag returns.
-type movedTagResult struct {
- // TagID is the tag that was moved.
- TagID xid.ID `json:"tag_id"`
-
- // SortIndex is the 0-based position it now has.
- SortIndex int `json:"sort_index"`
+
+ // Assigned is whether the branch now carries the tag.
+ Assigned bool `json:"assigned"`
}
diff --git a/server/core/internal/assistant/tools/tag_test.go b/server/core/internal/assistant/tools/tag_test.go
index beeda81f..c2ef6953 100644
--- a/server/core/internal/assistant/tools/tag_test.go
+++ b/server/core/internal/assistant/tools/tag_test.go
@@ -2,9 +2,11 @@ package tools
import (
"context"
+ "errors"
"fmt"
"testing"
+ "github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/tag"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
@@ -53,26 +55,14 @@ func Test_listTags_Info(t *testing.T) {
info := listTags{}.Info()
+ assert.Equal(t, Traits{}, info.Traits)
+
assert.Equal(t, NameListTags, info.Name)
assert.NotEmpty(t, info.Description)
assert.Empty(t, info.Properties)
assert.Empty(t, info.Required)
}
-func Test_listTags_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{}, listTags{}.Traits())
-}
-
-func Test_listTags_Title(t *testing.T) {
- t.Parallel()
-
- got, err := listTags{}.Title(testInput(testDeps(nil, nil, nil), NameListTags, `{}`))
- require.NoError(t, err)
- assert.Empty(t, got)
-}
-
func Test_listTags_Execute(t *testing.T) {
t.Parallel()
@@ -149,11 +139,39 @@ func Test_createTagArgs_Validate(t *testing.T) {
assert.Equal(t, tag.ErrInvalidTagColor, createTagArgs{Name: "Release", ColorName: "navy"}.Validate())
}
+func Test_createTagArgs_input(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Args createTagArgs
+ Result tag.CreateInput
+ }{
+ "Palette colour": {
+ Args: createTagArgs{Name: "Release", ColorName: "blue"},
+ Result: tag.CreateInput{TagName: "Release", Color: "#155dfc"},
+ },
+ "Colour outside the palette": {
+ Args: createTagArgs{Name: "Release", ColorName: "navy"},
+ Result: tag.CreateInput{TagName: "Release"},
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, c.Args.input())
+ })
+ }
+}
+
func Test_createTag_Info(t *testing.T) {
t.Parallel()
info := createTag{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameCreateTag, info.Name)
assert.NotEmpty(t, info.Description)
assert.Equal(t, []string{"name", "color"}, info.Required)
@@ -174,25 +192,6 @@ func colorEnum(t *testing.T, info Info) []string {
return names
}
-func Test_createTag_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, createTag{}.Traits())
-}
-
-func Test_createTag_Title(t *testing.T) {
- t.Parallel()
-
- d := testDeps(nil, nil, nil)
-
- got, err := createTag{}.Title(testInput(d, NameCreateTag, `{"name":"Release","color":"blue"}`))
- require.NoError(t, err)
- assert.Equal(t, "Creating tag Release", got)
-
- _, err = createTag{}.Title(testInput(d, NameCreateTag, `{}`))
- require.Error(t, err)
-}
-
func Test_createTag_Summary(t *testing.T) {
t.Parallel()
@@ -228,7 +227,7 @@ func Test_createTag_Execute(t *testing.T) {
},
},
Args: `{"name":"Release","color":"blue"}`,
- Err: fmt.Errorf("create_tag: %w", tag.ErrDuplicateTagName),
+ Err: tag.ErrDuplicateTagName,
},
"Error returned by db.InsertTag": {
DB: &DBMock{
@@ -281,9 +280,42 @@ func Test_updateTagArgs_Validate(t *testing.T) {
"tag_id": updateTagArgs{Name: "Release"},
})
- assert.Equal(t, tag.ErrEmptyTagUpdate, updateTagArgs{TagID: _testTagID}.Validate())
+ testutil.AssertEqualError(t, errors.New("give at least one of name, color or sort_index"), updateTagArgs{TagID: _testTagID}.Validate())
assert.Equal(t, tag.ErrInvalidTagColor, updateTagArgs{TagID: _testTagID, ColorName: "navy"}.Validate())
require.NoError(t, updateTagArgs{TagID: _testTagID, ColorName: "blue"}.Validate())
+
+ // the first position is a move like any other.
+ require.NoError(t, updateTagArgs{TagID: _testTagID, SortIndex: null.ValueFrom(0)}.Validate())
+}
+
+func Test_updateTagArgs_input(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Args updateTagArgs
+ Result tag.UpdateInput
+ }{
+ "Nothing given": {
+ Args: updateTagArgs{TagID: _testTagID},
+ Result: tag.UpdateInput{},
+ },
+ "Name and palette colour": {
+ Args: updateTagArgs{TagID: _testTagID, Name: "Release", ColorName: "blue"},
+ Result: tag.UpdateInput{TagName: null.StringFrom("Release"), Color: null.StringFrom("#155dfc")},
+ },
+ "Colour outside the palette": {
+ Args: updateTagArgs{TagID: _testTagID, ColorName: "navy"},
+ Result: tag.UpdateInput{Color: null.StringFrom("")},
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, c.Args.input())
+ })
+ }
}
func Test_updateTag_Info(t *testing.T) {
@@ -291,39 +323,17 @@ func Test_updateTag_Info(t *testing.T) {
info := updateTag{}.Info()
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
assert.Equal(t, NameUpdateTag, info.Name)
assert.NotEmpty(t, info.Description)
assert.Equal(t, []string{"tag_id"}, info.Required)
assert.Contains(t, info.Properties, "name")
+ assert.Contains(t, info.Properties, "sort_index")
+ assert.Contains(t, info.Properties, "sort_index")
assert.Equal(t, tag.ColorNames(), colorEnum(t, info))
}
-func Test_updateTag_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, updateTag{}.Traits())
-}
-
-func Test_updateTag_Title(t *testing.T) {
- t.Parallel()
-
- got, err := updateTag{}.Title(testInput(
- testDeps(stubTagDB(nil), nil, nil), NameUpdateTag,
- `{`+tagArgs(_testTagID)+`,"name":"Release"}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Updating tag Production", got)
-
- // the tag it names has to resolve; the failure is passed on rather
- // than described around.
- _, err = updateTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameUpdateTag, `{`+tagArgs(_unknownTagID)+`,"name":"Release"}`))
- require.Error(t, err)
-
- // unreadable arguments are refused before anything is looked up.
- _, err = updateTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameUpdateTag, `{`))
- require.Error(t, err)
-}
-
func Test_updateTag_Summary(t *testing.T) {
t.Parallel()
@@ -355,6 +365,22 @@ func Test_updateTag_Summary(t *testing.T) {
Args: `{` + tagArgs(_testTagID) + `,"name":"Release","color":"blue"}`,
Result: `Rename Production to "Release" and set the colour to blue`,
},
+ // the card counts positions from one, the way a person does.
+ "Move only": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
+ Result: "Move it to position 2",
+ },
+ "Rename and move": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","sort_index":0}`,
+ Result: `Rename Production to "Release" and move it to position 1`,
+ },
+ "Rename, recolour and move": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","color":"blue","sort_index":0}`,
+ Result: `Rename Production to "Release", set the colour to blue and move it to position 1`,
+ },
}
for cn, c := range cc {
@@ -376,12 +402,19 @@ func Test_updateTag_Summary(t *testing.T) {
func Test_updateTag_Execute(t *testing.T) {
t.Parallel()
+ failingTree := stubTagDB(nil)
+ failingTree.UpdateTagTreeFunc = func(context.Context, tag.Summaries, string) error {
+ return assert.AnError
+ }
+
cc := map[string]struct {
- DB *DBMock
- Args string
- Notify int
- Result string
- Err error
+ DB *DBMock
+ Args string
+ Notify int
+ Renames int
+ Order []xid.ID
+ Result string
+ Err error
}{
"Malformed arguments": {DB: &DBMock{}, Args: `{`, Err: assert.AnError},
"Tag id is required": {DB: &DBMock{}, Args: `{"name":"Release"}`, Err: assert.AnError},
@@ -393,8 +426,9 @@ func Test_updateTag_Execute(t *testing.T) {
return errutil.ErrNotFound
},
},
- Args: `{` + tagArgs(_unknownTagID) + `,"name":"Release"}`,
- Err: fmt.Errorf("update_tag: %w", fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag)),
+ Args: `{` + tagArgs(_unknownTagID) + `,"name":"Release"}`,
+ Renames: 1,
+ Err: fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag),
},
"Error returned by db.UpdateTag": {
DB: &DBMock{
@@ -402,20 +436,96 @@ func Test_updateTag_Execute(t *testing.T) {
return assert.AnError
},
},
- Args: `{` + tagArgs(_testTagID) + `,"name":"Release"}`,
- Err: assert.AnError,
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release"}`,
+ Renames: 1,
+ Err: assert.AnError,
},
"Renamed": {
- DB: &DBMock{},
- Args: `{` + tagArgs(_testTagID) + `,"name":"Release"}`,
- Notify: 1,
- Result: `{"tag_id":"` + _testTagID.String() + `","name":"Release"}`,
+ DB: &DBMock{},
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release"}`,
+ Renames: 1,
+ Notify: 1,
+ Result: `{"tag_id":"` + _testTagID.String() + `","name":"Release"}`,
},
"Renamed and recoloured": {
- DB: &DBMock{},
- Args: `{` + tagArgs(_testTagID) + `,"name":"Release","color":"blue"}`,
+ DB: &DBMock{},
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","color":"blue"}`,
+ Renames: 1,
+ Notify: 1,
+ Result: `{"tag_id":"` + _testTagID.String() + `","name":"Release","color":"blue"}`,
+ },
+ "Error returned by db.FetchTagTree": {
+ DB: stubTagDB(assert.AnError),
+ Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
+ Err: assert.AnError,
+ },
+ "Unknown tag to move": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_unknownTagID) + `,"sort_index":1}`,
+ Err: fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag),
+ },
+ "Sort index past the last tag": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"sort_index":2}`,
+ Err: assert.AnError,
+ },
+ "Negative sort index": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"sort_index":-1}`,
+ Err: assert.AnError,
+ },
+ "Error returned by db.UpdateTagTree": {
+ DB: failingTree,
+ Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
+ Err: assert.AnError,
+ },
+ "Moved to the end": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
Notify: 1,
- Result: `{"tag_id":"` + _testTagID.String() + `","name":"Release","color":"blue"}`,
+ Order: []xid.ID{_otherTagID, _testTagID},
+ Result: `{"tag_id":"` + _testTagID.String() + `","sort_index":1}`,
+ },
+ "Moved to the front": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_otherTagID) + `,"sort_index":0}`,
+ Notify: 1,
+ Order: []xid.ID{_otherTagID, _testTagID},
+ Result: `{"tag_id":"` + _otherTagID.String() + `","sort_index":0}`,
+ },
+ // a position the move would refuse fails the call before the
+ // rename is saved.
+ "Rename with a sort index past the last tag": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","sort_index":9}`,
+ Err: assert.AnError,
+ },
+ "Rename while the tags cannot be read": {
+ DB: stubTagDB(assert.AnError),
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","sort_index":1}`,
+ Err: assert.AnError,
+ },
+ "Renamed, then the move fails": {
+ DB: func() *DBMock {
+ db := stubTagDB(nil)
+ db.UpdateTagTreeFunc = func(context.Context, tag.Summaries, string) error {
+ return assert.AnError
+ }
+
+ return db
+ }(),
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","sort_index":1}`,
+ Renames: 1,
+ Notify: 1,
+ Err: assert.AnError,
+ },
+ "Renamed and moved": {
+ DB: stubTagDB(nil),
+ Args: `{` + tagArgs(_testTagID) + `,"name":"Release","sort_index":1}`,
+ Renames: 1,
+ Notify: 2,
+ Order: []xid.ID{_otherTagID, _testTagID},
+ Result: `{"tag_id":"` + _testTagID.String() + `","name":"Release","sort_index":1}`,
},
}
@@ -429,12 +539,31 @@ func Test_updateTag_Execute(t *testing.T) {
testutil.AssertEqualError(t, c.Err, err)
assert.Len(t, tags.NotifyTreeChangeCalls(), c.Notify)
+ assert.Len(t, c.DB.UpdateTagCalls(), c.Renames)
if err != nil {
return
}
assert.JSONEq(t, c.Result, res)
+
+ // a move rewrites the whole tree in its new order.
+ if c.Order == nil {
+ assert.Empty(t, c.DB.UpdateTagTreeCalls())
+
+ return
+ }
+
+ ff := c.DB.UpdateTagTreeCalls()
+ require.Len(t, ff, 1)
+ assert.Equal(t, "org", ff[0].OrganizationID)
+
+ order := make([]xid.ID, 0, len(ff[0].Tree))
+ for _, s := range ff[0].Tree {
+ order = append(order, s.ID)
+ }
+
+ assert.Equal(t, c.Order, order)
})
}
}
@@ -452,32 +581,14 @@ func Test_deleteTag_Info(t *testing.T) {
info := deleteTag{}.Info()
+ // a delete stays outside any "approve all" answer.
+ assert.Equal(t, Traits{Write: true, Destructive: true}, info.Traits)
+
assert.Equal(t, NameDeleteTag, info.Name)
assert.Contains(t, info.Description, "cannot be restored")
assert.Equal(t, []string{"tag_id"}, info.Required)
}
-func Test_deleteTag_Traits(t *testing.T) {
- t.Parallel()
-
- // a delete stays outside any "approve all" answer.
- assert.Equal(t, Traits{Write: true, Destructive: true}, deleteTag{}.Traits())
-}
-
-func Test_deleteTag_Title(t *testing.T) {
- t.Parallel()
-
- got, err := deleteTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameDeleteTag, `{`+tagArgs(_testTagID)+`}`))
- require.NoError(t, err)
- assert.Equal(t, "Deleting tag Production", got)
-
- _, err = deleteTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameDeleteTag, `{`+tagArgs(_unknownTagID)+`}`))
- require.Error(t, err)
-
- _, err = deleteTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameDeleteTag, `{`))
- require.Error(t, err)
-}
-
func Test_deleteTag_Summary(t *testing.T) {
t.Parallel()
@@ -546,7 +657,7 @@ func Test_deleteTag_Execute(t *testing.T) {
},
},
Args: `{` + tagArgs(_unknownTagID) + `}`,
- Err: fmt.Errorf("delete_tag: %w", fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag)),
+ Err: fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag),
},
"Error returned by db.DeleteTag": {
DB: &DBMock{
@@ -588,61 +699,32 @@ func Test_deleteTag_Execute(t *testing.T) {
}
}
-func Test_branchTagArgs_Validate(t *testing.T) {
+func Test_setTagAssignmentArgs_Validate(t *testing.T) {
t.Parallel()
- ok := branchTagArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, TagID: _testTagID}
+ ok := setTagAssignmentArgs{DocumentID: _testDocID, BranchID: _stubMainBranchID, TagID: _testTagID, Assigned: null.ValueFrom(false)}
assertValidate(t, ok, map[string]Args{
- "document_id": branchTagArgs{docTarget: docTarget{BranchID: _stubMainBranchID}, TagID: _testTagID},
- "branch_id": branchTagArgs{docTarget: docTarget{DocumentID: _testDocID}, TagID: _testTagID},
- "tag_id": branchTagArgs{docTarget: docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}},
+ "document_id": setTagAssignmentArgs{docTarget: docTarget{BranchID: _stubMainBranchID}, TagID: _testTagID, Assigned: null.ValueFrom(true)},
+ "branch_id": setTagAssignmentArgs{docTarget: docTarget{DocumentID: _testDocID}, TagID: _testTagID, Assigned: null.ValueFrom(true)},
+ "tag_id": setTagAssignmentArgs{docTarget: docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}, Assigned: null.ValueFrom(true)},
+ "assigned": setTagAssignmentArgs{docTarget: docTarget{DocumentID: _testDocID, BranchID: _stubMainBranchID}, TagID: _testTagID},
})
}
-func Test_assignTag_Info(t *testing.T) {
+func Test_setTagAssignment_Info(t *testing.T) {
t.Parallel()
- info := assignTag{}.Info()
+ info := setTagAssignment{}.Info()
- assert.Equal(t, NameAssignTag, info.Name)
+ assert.Equal(t, Traits{Write: true}, info.Traits)
+
+ assert.Equal(t, NameSetTagAssignment, info.Name)
assert.NotEmpty(t, info.Description)
- assert.Equal(t, []string{"document_id", "branch_id", "tag_id"}, info.Required)
- assert.Contains(t, info.Properties, "branch_id")
+ assert.Equal(t, []string{"document_id", "branch_id", "tag_id", "assigned"}, info.Required)
}
-func Test_assignTag_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, assignTag{}.Traits())
-}
-
-func Test_assignTag_Title(t *testing.T) {
- t.Parallel()
-
- got, err := assignTag{}.Title(testInput(
- testDeps(stubTagDB(nil), nil, nil), NameAssignTag,
- `{`+targetArgs(_stubMainBranchID)+`,`+tagArgs(_testTagID)+`}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Tagging Runbook", got)
-
- // a branch other than the default one is named.
- got, err = assignTag{}.Title(testInput(
- testDeps(stubTagDB(nil), nil, nil), NameAssignTag,
- `{`+targetArgs(_stubBranchID)+`,`+tagArgs(_testTagID)+`}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Tagging Runbook on branch draft", got)
-
- _, err = assignTag{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameAssignTag, requiredArgs(t, NameAssignTag)))
- require.Error(t, err)
-
- _, err = assignTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameAssignTag, `{`))
- require.Error(t, err)
-}
-
-func Test_assignTag_Summary(t *testing.T) {
+func Test_setTagAssignment_Summary(t *testing.T) {
t.Parallel()
cc := map[string]struct {
@@ -654,188 +736,29 @@ func Test_assignTag_Summary(t *testing.T) {
"Malformed arguments": {DB: stubTagDB(nil), Args: `{`, Err: assert.AnError},
"Unknown branch": {
DB: stubTagDB(nil),
- Args: `{` + targetArgs(_unknownBranchID) + `,` + tagArgs(_testTagID) + `}`,
+ Args: `{` + targetArgs(_unknownBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":true}`,
Err: assert.AnError,
},
"Unknown tag": {
DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_unknownTagID) + `}`,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_unknownTagID) + `,"assigned":true}`,
Err: assert.AnError,
},
- "Names the tag and the document": {
+ "Putting the tag on": {
DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubBranchID) + `,` + tagArgs(_testTagID) + `}`,
+ Args: `{` + targetArgs(_stubBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":true}`,
Result: ActionSummary{
- Tool: NameAssignTag,
+ Tool: NameSetTagAssignment,
DocumentID: _testDocID,
DocumentName: _stubDocumentName,
Summary: "Put tag Production on Runbook on branch draft",
},
},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- got, err := assignTag{}.Summary(testInput(testDeps(c.DB, nil, nil), NameAssignTag, c.Args))
- testutil.AssertEqualError(t, c.Err, err)
-
- if err != nil {
- return
- }
-
- assert.Equal(t, c.Result, got)
- })
- }
-}
-
-func Test_assignTag_Execute(t *testing.T) {
- t.Parallel()
-
- unknownTag := stubTagDB(nil)
- unknownTag.AssignBranchTagFunc = func(context.Context, string, xid.ID, xid.ID, xid.ID) error {
- return errutil.ErrNotFound
- }
-
- failing := stubTagDB(nil)
- failing.AssignBranchTagFunc = func(context.Context, string, xid.ID, xid.ID, xid.ID) error {
- return assert.AnError
- }
-
- cc := map[string]struct {
- DB *DBMock
- Args string
- Notify int
- Err error
- }{
- "Malformed arguments": {DB: stubTagDB(nil), Args: `{`, Err: assert.AnError},
- "Tag id is required": {DB: stubTagDB(nil), Args: `{` + targetArgs(_stubMainBranchID) + `}`, Err: assert.AnError},
- "Unknown branch": {
+ "Taking the tag off": {
DB: stubTagDB(nil),
- Args: `{` + targetArgs(_unknownBranchID) + `,` + tagArgs(_testTagID) + `}`,
- Err: assert.AnError,
- },
- "Unknown tag": {
- DB: unknownTag,
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_unknownTagID) + `}`,
- Err: fmt.Errorf("assign_tag: %w", fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag)),
- },
- "Error returned by db.AssignBranchTag": {
- DB: failing,
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `}`,
- Err: assert.AnError,
- },
- // the repository treats a tag the branch already carries as
- // nothing to do, so the call reads the same as a fresh one.
- "Assigned": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubBranchID) + `,` + tagArgs(_testTagID) + `}`,
- Notify: 1,
- },
- "Hidden tag is assigned all the same": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubBranchID) + `,` + tagArgs(_otherTagID) + `}`,
- Notify: 1,
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- d, tags := tagDeps(c.DB)
- inp := testInput(d, NameAssignTag, c.Args)
-
- res, err := assignTag{}.Execute(inp)
- testutil.AssertEqualError(t, c.Err, err)
-
- assert.Len(t, tags.NotifyTreeChangeCalls(), c.Notify)
- assert.Len(t, tags.NotifyBranchTagsChangeCalls(), c.Notify)
-
- if err != nil {
- return
- }
-
- ff := c.DB.AssignBranchTagCalls()
- require.Len(t, ff, 1)
- assert.Equal(t, "org", ff[0].OrganizationID)
- assert.Equal(t, _testDocID, ff[0].DocumentID)
- assert.Equal(t, _stubBranchID, ff[0].BranchID)
-
- // the header of an open document refreshes from its own topic,
- // while the sidebar refreshes from the tree's.
- bf := tags.NotifyBranchTagsChangeCalls()
- require.Len(t, bf, 1)
- assert.Equal(t, "org", bf[0].OrganizationID)
- assert.Equal(t, _testDocID, bf[0].DocumentID)
- assert.Equal(t, _stubBranchID, bf[0].BranchID)
-
- assert.JSONEq(t, `{"document_id":"`+_testDocID.String()+`","branch_id":"`+_stubBranchID.String()+`","tag_id":"`+ff[0].TagID.String()+`"}`, res)
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubBranchID}}, inp.touched)
- })
- }
-}
-
-func Test_unassignTag_Info(t *testing.T) {
- t.Parallel()
-
- info := unassignTag{}.Info()
-
- assert.Equal(t, NameUnassignTag, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Equal(t, []string{"document_id", "branch_id", "tag_id"}, info.Required)
- assert.Contains(t, info.Properties, "branch_id")
-}
-
-func Test_unassignTag_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, unassignTag{}.Traits())
-}
-
-func Test_unassignTag_Title(t *testing.T) {
- t.Parallel()
-
- got, err := unassignTag{}.Title(testInput(
- testDeps(stubTagDB(nil), nil, nil), NameUnassignTag,
- `{`+targetArgs(_stubMainBranchID)+`,`+tagArgs(_testTagID)+`}`,
- ))
- require.NoError(t, err)
- assert.Equal(t, "Untagging Runbook", got)
-
- _, err = unassignTag{}.Title(testInput(testDeps(failingDocumentDB(), nil, nil), NameUnassignTag, requiredArgs(t, NameUnassignTag)))
- require.Error(t, err)
-
- _, err = unassignTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameUnassignTag, `{`))
- require.Error(t, err)
-}
-
-func Test_unassignTag_Summary(t *testing.T) {
- t.Parallel()
-
- cc := map[string]struct {
- DB *DBMock
- Args string
- Result ActionSummary
- Err error
- }{
- "Malformed arguments": {DB: stubTagDB(nil), Args: `{`, Err: assert.AnError},
- "Unknown branch": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_unknownBranchID) + `,` + tagArgs(_testTagID) + `}`,
- Err: assert.AnError,
- },
- "Unknown tag": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_unknownTagID) + `}`,
- Err: assert.AnError,
- },
- "Names the tag and the document": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `}`,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":false}`,
Result: ActionSummary{
- Tool: NameUnassignTag,
+ Tool: NameSetTagAssignment,
DocumentID: _testDocID,
DocumentName: _stubDocumentName,
Summary: "Take tag Production off Runbook",
@@ -847,7 +770,7 @@ func Test_unassignTag_Summary(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- got, err := unassignTag{}.Summary(testInput(testDeps(c.DB, nil, nil), NameUnassignTag, c.Args))
+ got, err := setTagAssignment{}.Summary(testInput(testDeps(c.DB, nil, nil), NameSetTagAssignment, c.Args))
testutil.AssertEqualError(t, c.Err, err)
if err != nil {
@@ -859,48 +782,96 @@ func Test_unassignTag_Summary(t *testing.T) {
}
}
-func Test_unassignTag_Execute(t *testing.T) {
+func Test_setTagAssignment_Execute(t *testing.T) {
t.Parallel()
- failing := stubTagDB(nil)
- failing.UnassignBranchTagFunc = func(context.Context, string, xid.ID, xid.ID, xid.ID) error {
- return assert.AnError
+ type tcase struct {
+ DB *DBMock
+ Args string
+ Notify int
+ Assigns int
+ Removes int
+ BranchID xid.ID
+ Result string
+ Err error
}
- cc := map[string]struct {
- DB *DBMock
- Args string
- Notify int
- Err error
- }{
+ cc := map[string]tcase{
"Malformed arguments": {DB: stubTagDB(nil), Args: `{`, Err: assert.AnError},
- "Tag id is required": {DB: stubTagDB(nil), Args: `{` + targetArgs(_stubMainBranchID) + `}`, Err: assert.AnError},
- "Unknown branch": {
+ "Assigned is required": {
DB: stubTagDB(nil),
- Args: `{` + targetArgs(_unknownBranchID) + `,` + tagArgs(_testTagID) + `}`,
- Err: assert.AnError,
- },
- "Unknown tag": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_unknownTagID) + `}`,
- Err: fmt.Errorf("unassign_tag: %w", fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag)),
- },
- "Error returned by db.UnassignBranchTag": {
- DB: failing,
Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `}`,
Err: assert.AnError,
},
- // the repository treats a tag the branch does not carry as
- // nothing to do, so the call reads the same either way.
- "Unassigned": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `}`,
- Notify: 1,
+ "Unknown branch": {
+ DB: stubTagDB(nil),
+ Args: `{` + targetArgs(_unknownBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":true}`,
+ Err: assert.AnError,
},
- "Hidden tag is unassigned all the same": {
- DB: stubTagDB(nil),
- Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_otherTagID) + `}`,
- Notify: 1,
+ "Unknown tag": func() tcase {
+ db := stubTagDB(nil)
+ db.AssignBranchTagFunc = func(context.Context, string, xid.ID, xid.ID, xid.ID) error {
+ return errutil.ErrNotFound
+ }
+
+ return tcase{
+ DB: db,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_unknownTagID) + `,"assigned":true}`,
+ Assigns: 1,
+ Err: fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag),
+ }
+ }(),
+ "Error returned by db.AssignBranchTag": func() tcase {
+ db := stubTagDB(nil)
+ db.AssignBranchTagFunc = func(context.Context, string, xid.ID, xid.ID, xid.ID) error {
+ return assert.AnError
+ }
+
+ return tcase{
+ DB: db,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":true}`,
+ Assigns: 1,
+ Err: assert.AnError,
+ }
+ }(),
+ "Error returned by db.UnassignBranchTag": func() tcase {
+ db := stubTagDB(nil)
+ db.UnassignBranchTagFunc = func(context.Context, string, xid.ID, xid.ID, xid.ID) error {
+ return assert.AnError
+ }
+
+ return tcase{
+ DB: db,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":false}`,
+ Removes: 1,
+ Err: assert.AnError,
+ }
+ }(),
+ // the repository treats a tag the branch already carries as
+ // nothing to do, so the call reads the same as a fresh one.
+ "Assigned": {
+ DB: stubTagDB(nil),
+ Args: `{` + targetArgs(_stubBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":true}`,
+ Notify: 1,
+ Assigns: 1,
+ BranchID: _stubBranchID,
+ Result: `{"document_id":"` + _testDocID.String() + `","branch_id":"` + _stubBranchID.String() + `","tag_id":"` + _testTagID.String() + `","assigned":true}`,
+ },
+ "Hidden tag is assigned all the same": {
+ DB: stubTagDB(nil),
+ Args: `{` + targetArgs(_stubBranchID) + `,` + tagArgs(_otherTagID) + `,"assigned":true}`,
+ Notify: 1,
+ Assigns: 1,
+ BranchID: _stubBranchID,
+ Result: `{"document_id":"` + _testDocID.String() + `","branch_id":"` + _stubBranchID.String() + `","tag_id":"` + _otherTagID.String() + `","assigned":true}`,
+ },
+ "Unassigned": {
+ DB: stubTagDB(nil),
+ Args: `{` + targetArgs(_stubMainBranchID) + `,` + tagArgs(_testTagID) + `,"assigned":false}`,
+ Notify: 1,
+ Removes: 1,
+ BranchID: _stubMainBranchID,
+ Result: `{"document_id":"` + _testDocID.String() + `","branch_id":"` + _stubMainBranchID.String() + `","tag_id":"` + _testTagID.String() + `","assigned":false}`,
},
}
@@ -909,11 +880,13 @@ func Test_unassignTag_Execute(t *testing.T) {
t.Parallel()
d, tags := tagDeps(c.DB)
- inp := testInput(d, NameUnassignTag, c.Args)
+ inp := testInput(d, NameSetTagAssignment, c.Args)
- res, err := unassignTag{}.Execute(inp)
+ res, err := setTagAssignment{}.Execute(inp)
testutil.AssertEqualError(t, c.Err, err)
+ assert.Len(t, c.DB.AssignBranchTagCalls(), c.Assigns)
+ assert.Len(t, c.DB.UnassignBranchTagCalls(), c.Removes)
assert.Len(t, tags.NotifyTreeChangeCalls(), c.Notify)
assert.Len(t, tags.NotifyBranchTagsChangeCalls(), c.Notify)
@@ -921,187 +894,15 @@ func Test_unassignTag_Execute(t *testing.T) {
return
}
- ff := c.DB.UnassignBranchTagCalls()
- require.Len(t, ff, 1)
- assert.Equal(t, "org", ff[0].OrganizationID)
- assert.Equal(t, _testDocID, ff[0].DocumentID)
- assert.Equal(t, _stubMainBranchID, ff[0].BranchID)
-
+ // the header of an open document refreshes from its own topic,
+ // while the sidebar refreshes from the tree's.
bf := tags.NotifyBranchTagsChangeCalls()
require.Len(t, bf, 1)
assert.Equal(t, "org", bf[0].OrganizationID)
assert.Equal(t, _testDocID, bf[0].DocumentID)
- assert.Equal(t, _stubMainBranchID, bf[0].BranchID)
+ assert.Equal(t, c.BranchID, bf[0].BranchID)
- assert.JSONEq(t, `{"document_id":"`+_testDocID.String()+`","branch_id":"`+_stubMainBranchID.String()+`","tag_id":"`+ff[0].TagID.String()+`"}`, res)
- assert.Equal(t, []Touched{{DocumentID: _testDocID, BranchID: _stubMainBranchID}}, inp.touched)
- })
- }
-}
-
-func Test_moveTagArgs_Validate(t *testing.T) {
- t.Parallel()
-
- var (
- first = `{` + tagArgs(_testTagID) + `,"sort_index":0}`
- noTag = `{"sort_index":0}`
- noSort = `{` + tagArgs(_testTagID) + `}`
- )
-
- // the first position is a legal answer, so presence is what is
- // checked rather than a zero value; the arguments are decoded here
- // because that is what tells the two apart.
- decode := func(t *testing.T, raw string) moveTagArgs {
- t.Helper()
-
- var in moveTagArgs
- require.NoError(t, testInput(testDeps(nil, nil, nil), NameMoveTag, raw).Decode(&in))
-
- return in
- }
-
- assertValidate(t, decode(t, first), nil)
-
- for key, raw := range map[string]string{"tag_id": noTag, "sort_index": noSort} {
- var in moveTagArgs
-
- err := testInput(testDeps(nil, nil, nil), NameMoveTag, raw).Decode(&in)
- require.Error(t, err, "%s should be required", key)
- assert.Contains(t, err.Error(), key+" is required")
- }
-}
-
-func Test_moveTag_Info(t *testing.T) {
- t.Parallel()
-
- info := moveTag{}.Info()
-
- assert.Equal(t, NameMoveTag, info.Name)
- assert.NotEmpty(t, info.Description)
- assert.Equal(t, []string{"tag_id", "sort_index"}, info.Required)
- assert.Contains(t, info.Properties, "sort_index")
-}
-
-func Test_moveTag_Traits(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, Traits{Write: true}, moveTag{}.Traits())
-}
-
-func Test_moveTag_Title(t *testing.T) {
- t.Parallel()
-
- got, err := moveTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameMoveTag, `{`+tagArgs(_testTagID)+`,"sort_index":1}`))
- require.NoError(t, err)
- assert.Equal(t, "Moving tag Production", got)
-
- _, err = moveTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameMoveTag, `{`+tagArgs(_unknownTagID)+`,"sort_index":1}`))
- require.Error(t, err)
-
- _, err = moveTag{}.Title(testInput(testDeps(stubTagDB(nil), nil, nil), NameMoveTag, `{`))
- require.Error(t, err)
-}
-
-func Test_moveTag_Summary(t *testing.T) {
- t.Parallel()
-
- got, err := moveTag{}.Summary(testInput(testDeps(stubTagDB(nil), nil, nil), NameMoveTag, `{`+tagArgs(_testTagID)+`,"sort_index":1}`))
- require.NoError(t, err)
-
- // the card counts positions from one, the way a person does.
- assert.Equal(t, ActionSummary{Tool: NameMoveTag, Summary: "Move tag Production to position 2"}, got)
-
- _, err = moveTag{}.Summary(testInput(testDeps(stubTagDB(nil), nil, nil), NameMoveTag, `{`+tagArgs(_unknownTagID)+`,"sort_index":1}`))
- require.Error(t, err)
-
- _, err = moveTag{}.Summary(testInput(testDeps(stubTagDB(nil), nil, nil), NameMoveTag, `{`))
- require.Error(t, err)
-}
-
-func Test_moveTag_Execute(t *testing.T) {
- t.Parallel()
-
- failing := stubTagDB(nil)
- failing.UpdateTagTreeFunc = func(context.Context, tag.Summaries, string) error {
- return assert.AnError
- }
-
- cc := map[string]struct {
- DB *DBMock
- Args string
- Notify int
- Order []xid.ID
- Err error
- }{
- "Malformed arguments": {DB: stubTagDB(nil), Args: `{`, Err: assert.AnError},
- "Tag id is required": {DB: stubTagDB(nil), Args: `{"sort_index":0}`, Err: assert.AnError},
- "Sort index is required": {DB: stubTagDB(nil), Args: `{` + tagArgs(_testTagID) + `}`, Err: assert.AnError},
- "Error returned by db.FetchTagTree": {
- DB: stubTagDB(assert.AnError),
- Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
- Err: assert.AnError,
- },
- "Unknown tag": {
- DB: stubTagDB(nil),
- Args: `{` + tagArgs(_unknownTagID) + `,"sort_index":1}`,
- Err: fmt.Errorf("move_tag: %w", fmt.Errorf("tag %s: %w", _unknownTagID, ErrUnknownTag)),
- },
- "Sort index past the last tag": {
- DB: stubTagDB(nil),
- Args: `{` + tagArgs(_testTagID) + `,"sort_index":2}`,
- Err: assert.AnError,
- },
- "Negative sort index": {
- DB: stubTagDB(nil),
- Args: `{` + tagArgs(_testTagID) + `,"sort_index":-1}`,
- Err: assert.AnError,
- },
- "Error returned by db.UpdateTagTree": {
- DB: failing,
- Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
- Err: assert.AnError,
- },
- "Moved to the end": {
- DB: stubTagDB(nil),
- Args: `{` + tagArgs(_testTagID) + `,"sort_index":1}`,
- Notify: 1,
- Order: []xid.ID{_otherTagID, _testTagID},
- },
- "Moved to the front": {
- DB: stubTagDB(nil),
- Args: `{` + tagArgs(_otherTagID) + `,"sort_index":0}`,
- Notify: 1,
- Order: []xid.ID{_otherTagID, _testTagID},
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- d, tags := tagDeps(c.DB)
-
- res, err := moveTag{}.Execute(testInput(d, NameMoveTag, c.Args))
- testutil.AssertEqualError(t, c.Err, err)
-
- assert.Len(t, tags.NotifyTreeChangeCalls(), c.Notify)
-
- if err != nil {
- return
- }
-
- // the whole tree is rewritten in its new order.
- ff := c.DB.UpdateTagTreeCalls()
- require.Len(t, ff, 1)
- assert.Equal(t, "org", ff[0].OrganizationID)
-
- order := make([]xid.ID, 0, len(ff[0].Tree))
- for _, s := range ff[0].Tree {
- order = append(order, s.ID)
- }
-
- assert.Equal(t, c.Order, order)
- assert.Contains(t, res, `"sort_index":`)
+ assert.JSONEq(t, c.Result, res)
})
}
}
diff --git a/server/core/internal/assistant/tools/testdata/tool_schemas.golden b/server/core/internal/assistant/tools/testdata/tool_schemas.golden
index 8bfa0999..dac6e211 100644
--- a/server/core/internal/assistant/tools/testdata/tool_schemas.golden
+++ b/server/core/internal/assistant/tools/testdata/tool_schemas.golden
@@ -1 +1 @@
-{"assign_tag":{"desc":"Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"assign_tag"},"create_document":{"desc":"Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.","has_params_one_of":true,"json_schema":{"properties":{"icon":{"description":"Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty.","type":"string"},"name":{"description":"Display name for the new document.","type":"string"},"parent_id":{"description":"Optional parent document id. Omit to create at the org root.","type":"string"}},"required":["name"],"type":"object"},"name":"create_document"},"create_hook":{"desc":"Add a freshness hook to one branch of a document and return it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole.","type":"string"},"branch_id":{"description":"The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"The RFC 3339 timestamp at which the reminder is due.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"title":"scheduled-reminder","type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The repository branch to follow.","type":"string"},"paths":{"description":"The repository paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository to follow, by its name alone; the connected GitHub account is its owner.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"title":"github-tracking","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The web address to watch for changes.","type":"string"}},"required":["type","url"],"title":"url-watcher","type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The container image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"title":"container-image-watcher","type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","branch_id","settings"],"type":"object"},"name":"create_hook"},"create_tag":{"desc":"Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color one of the palette names the schema lists; a colour outside the palette or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"The tag's colour, one of the palette names.","enum":["red","orange","amber","yellow","lime","green","emerald","teal","cyan","sky","blue","indigo","violet","purple","fuchsia","pink"],"type":"string"},"name":{"description":"Display name for the new tag; unique within the organisation.","type":"string"}},"required":["name","color"],"type":"object"},"name":"create_tag"},"delete_block":{"desc":"Delete one block by uid, including anything nested inside it. Its comments, hooks and files go with it and cannot be restored, so use move_block when the aim is to reorder and update_block_text when the aim is new wording. Returns {deleted: uid}.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to delete.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"delete_block"},"delete_document":{"desc":"Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id to delete.","type":"string"}},"required":["document_id"],"type":"object"},"name":"delete_document"},"delete_hook":{"desc":"Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"delete_hook"},"delete_tag":{"desc":"Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"tag_id":{"description":"The id of the tag to delete.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"delete_tag"},"get_document":{"desc":"Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}] with color a palette name as list_tags reports it, followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"get_document"},"get_prometheus_metadata":{"desc":"List the metrics a Prometheus data source exposes, with each metric's type, help text and unit. Use it to find the metric names to write a PromQL query against instead of guessing them.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_prometheus_metadata"},"get_sql_metadata":{"desc":"List the tables and their columns in a PostgreSQL, MariaDB or MySQL data source, plus the default schema. Read it before writing a query so the table and column names are the real ones.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_sql_metadata"},"get_sql_query_labels":{"desc":"Run a SQL query limited to one row and return its string columns with an example value each, which are the columns a chart would treat as series labels. Use it to check what a query returns before charting it; it is cheaper than query_sql.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to probe. $__ macros are expanded as they are for a metric block.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"get_sql_query_labels"},"insert_block":{"desc":"Insert one canonical block into a document. position start or end puts it at the document's start or end; before or after puts it beside the block reference_block_uid names, which stays in place. The block has to be legal where it lands: the document root takes every type except titled_code, metric and split_doc_param_list, and a reference inside a split_doc or metric_grid takes only what that container holds. Returns the summary rows of the new block and the blocks nested in it that get_document lists, uids included, with depth counted from the block itself; a row marked has_children holds more, which read_block returns.","has_params_one_of":true,"json_schema":{"properties":{"block":{"anyOf":[{"additionalProperties":false,"properties":{"children":{"description":"Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"paragraph"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"paragraph","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"level":{"description":"The heading level.","enum":[1,2,3],"type":"integer"}},"required":["level"],"type":"object"},"text":{"description":"Plain text; a heading takes no formatting, so no markdown is parsed.","type":"string"},"type":{"const":"heading"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"heading","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The blocks it quotes, of any type legal at the document root. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"blockquote"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"blockquote","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"bullet_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"bullet_list","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"ordered_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"ordered_list","type":"object"},{"additionalProperties":false,"properties":{"task_items":{"description":"The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.","items":{"properties":{"block":{"type":"object"},"checked":{"type":"boolean"}},"required":["block"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"task_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","task_items"],"title":"task_list","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"icon":{"description":"Iconify identifier; defaults to lucide:text.","type":"string"}},"type":"object"},"items":{"description":"The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"callout"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"callout","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"}},"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"code","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"},"title":{"description":"Plain-text label naming what the code is. For an endpoint, the method and route, such as POST /v1/assets/{id}/due-date/preview.","type":"string"}},"required":["title"],"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"titled_code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"titled_code","type":"object"},{"additionalProperties":false,"properties":{"text":{"description":"Raw Mermaid source; no markdown. Mermaid ends a gantt task name or a timeline period at its first colon, so write any colon inside one as #58; (13#58;00).","type":"string"},"type":{"const":"mermaid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"mermaid","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"horizontal_rule"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"horizontal_rule","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"alt":{"description":"Alternative text.","type":"string"},"src":{"description":"The image address.","type":"string"},"title":{"description":"Caption.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"image"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"image","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"height":{"description":"Display height in pixels.","type":"integer"},"src":{"description":"The Figma frame address.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"figma"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"figma","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"description":"The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.","properties":{"axisBoundsMax":{"description":"Optional fixed upper bound of the value axis.","type":"number"},"axisBoundsMin":{"description":"Optional fixed lower bound of the value axis.","type":"number"},"baseThresholdColor":{"description":"Optional colour of the values below the first threshold.","type":"string"},"dataSourceId":{"description":"The data source the chart queries, an id from list_data_sources. Required for the block to render.","type":"string"},"decimals":{"description":"Optional number of decimals shown.","type":"number"},"queries":{"description":"The queries charted, in PromQL or SQL as the data source takes. A SQL chart selects a time column aliased \"time\" plus one or more numeric columns, and may use the $__ macros.","items":{"properties":{"legendFormat":{"description":"Optional series label template; may be empty.","type":"string"},"name":{"description":"The query's name.","type":"string"},"query":{"description":"The query itself.","type":"string"}},"required":["name","query"],"type":"object"},"type":"array"},"refreshInterval":{"description":"How often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"thresholds":{"description":"Optional threshold rows, each colouring the values from its value up.","items":{"properties":{"color":{"type":"string"},"label":{"type":"string"},"value":{"type":"number"}},"type":"object"},"type":"array"},"timeRange":{"description":"The window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"title":{"description":"Optional chart title.","type":"string"},"unitCustom":{"description":"The unit label, read when unitType is custom.","type":"string"},"unitType":{"description":"The unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"The chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"width":{"description":"The chart's size.","enum":["compact","standard","wide"],"type":"string"}},"type":"object"},"type":{"const":"metric"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"metric","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The metric blocks it holds.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"metric_grid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"metric_grid","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"inversed":{"description":"Flips the two sides.","type":"boolean"}},"type":"object"},"left":{"description":"The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.","items":{"type":"object"},"minItems":1,"type":"array"},"right":{"description":"The example side. Holds titled_code, metric or mermaid only, and is the one place titled_code is legal.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","left","right"],"title":"split_doc","type":"object"},{"additionalProperties":false,"properties":{"header":{"description":"The section header, as plain text.","type":"string"},"params":{"description":"The rows.","items":{"properties":{"description":{"description":"The description, in minimal markdown.","type":"string"},"name":{"description":"The parameter name, as plain text.","type":"string"},"type":{"description":"The parameter type, as plain text.","type":"string"}},"required":["name"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc_param_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","header","params"],"title":"split_doc_param_list","type":"object"}],"description":"A canonical block. Its type decides which fields it takes."},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Where the block lands: before or after the reference block, or at the start or end of the document.","enum":["before","after","start","end"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to insert beside. Required with before and after; leave it out with start and end.","type":"string"}},"required":["document_id","branch_id","position","block"],"type":"object"},"name":"insert_block"},"list_data_sources":{"desc":"List the organisation's data sources. Returns id, name, type (prometheus, postgresql, mariadb, mysql) and connection status. Start here: every other data-source tool takes an id from this list, and the type decides which of them serves it.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_data_sources"},"list_documents":{"desc":"List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.","has_params_one_of":true,"json_schema":{"properties":{"parent_id":{"description":"Optional. Return only the direct children of this document. Omit for the full tree.","type":"string"}},"type":"object"},"name":"list_documents"},"list_hooks":{"desc":"List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"list_hooks"},"list_prometheus_label_names":{"desc":"List the label names present in a Prometheus data source, optionally only those on the series the matchers select. Pair it with list_prometheus_label_values to build a filtered PromQL query.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"list_prometheus_label_names"},"list_prometheus_label_values":{"desc":"List the values a label takes in a Prometheus data source, optionally only on the series the matchers select. Use it to discover the concrete label values a query should filter on.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"label":{"description":"The label whose values to list, e.g. \"job\".","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","label"],"type":"object"},"name":"list_prometheus_label_values"},"list_prometheus_series":{"desc":"List the series matching a set of selectors in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric actually has before querying it.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","matchers"],"type":"object"},"name":"list_prometheus_series"},"list_tags":{"desc":"List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where color is a palette name, or unknown for a tag whose stored colour is outside the palette and wants recolouring with update_tag, and documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_tags"},"move_block":{"desc":"Move an existing block before or after another block in the same document. The block keeps its uid, attrs and nested content, so comments, hooks and files attached to it stay attached; deleting it and inserting a copy would lose them. The landing spot has to accept the block's type, by the same rule insert_block applies. Returns the summary rows of the moved block and everything nested in it.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to move.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Landing side relative to the reference block.","enum":["before","after"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to move relative to.","type":"string"}},"required":["document_id","branch_id","block_uid","position","reference_block_uid"],"type":"object"},"name":"move_block"},"move_tag":{"desc":"Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.","has_params_one_of":true,"json_schema":{"properties":{"sort_index":{"description":"The 0-based position the tag should end up at among the organisation's tags.","type":"integer"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id","sort_index"],"type":"object"},"name":"move_tag"},"query_prometheus":{"desc":"Run a PromQL range query against a Prometheus data source over the given window. Returns the raw result by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The PromQL expression to run.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_prometheus"},"query_sql":{"desc":"Run a read-only query against a PostgreSQL, MariaDB or MySQL data source. Returns columns and rows by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block. $__ macros ($__timeFilter, $__timeGroupAlias and the rest) are expanded against the window.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to run. For a chart, select a time column aliased \"time\" plus one or more numeric columns.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_sql"},"read_block":{"desc":"Return the full canonical content of one block by uid, including any nested children. Use it only when get_document's rows are not enough: to edit a split_doc, a nested list or a split_doc_param_list, whose inner structure has to be written back in full. A uid inside a block, such as a search hit on a titled_code's title, returns the block holding it, and a list entry's uid returns the entry. Fails when the uid is not in the document.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The block uid to fetch.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"read_block"},"read_tool_output":{"desc":"Retrieve the full output of an earlier tool call that was too large to keep in the conversation. Pass the path shown in the truncation notice.","has_params_one_of":true,"json_schema":{"properties":{"file_path":{"description":"The stored output path from the truncation notice.","type":"string"}},"required":["file_path"],"type":"object"},"name":"read_tool_output"},"replace_block":{"desc":"Replace a block by uid with a new block in the same position. The old block's uid, content and children are all gone unless the new block carries them, so use it to change a block's type or its whole structure. For a wording change use update_block_text, and for an attribute change update_block_attrs; both keep the uid, which comments, hooks and files hang off. Returns the summary rows of the new block and the blocks nested in it that get_document lists, uids included, with depth counted from the block itself; a row marked has_children holds more, which read_block returns.","has_params_one_of":true,"json_schema":{"properties":{"block":{"anyOf":[{"additionalProperties":false,"properties":{"children":{"description":"Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"paragraph"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"paragraph","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"level":{"description":"The heading level.","enum":[1,2,3],"type":"integer"}},"required":["level"],"type":"object"},"text":{"description":"Plain text; a heading takes no formatting, so no markdown is parsed.","type":"string"},"type":{"const":"heading"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"heading","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The blocks it quotes, of any type legal at the document root. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"blockquote"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"blockquote","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"bullet_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"bullet_list","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"ordered_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"ordered_list","type":"object"},{"additionalProperties":false,"properties":{"task_items":{"description":"The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.","items":{"properties":{"block":{"type":"object"},"checked":{"type":"boolean"}},"required":["block"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"task_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","task_items"],"title":"task_list","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"icon":{"description":"Iconify identifier; defaults to lucide:text.","type":"string"}},"type":"object"},"items":{"description":"The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"callout"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"callout","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"}},"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"code","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"},"title":{"description":"Plain-text label naming what the code is. For an endpoint, the method and route, such as POST /v1/assets/{id}/due-date/preview.","type":"string"}},"required":["title"],"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"titled_code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"titled_code","type":"object"},{"additionalProperties":false,"properties":{"text":{"description":"Raw Mermaid source; no markdown. Mermaid ends a gantt task name or a timeline period at its first colon, so write any colon inside one as #58; (13#58;00).","type":"string"},"type":{"const":"mermaid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"mermaid","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"horizontal_rule"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"horizontal_rule","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"alt":{"description":"Alternative text.","type":"string"},"src":{"description":"The image address.","type":"string"},"title":{"description":"Caption.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"image"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"image","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"height":{"description":"Display height in pixels.","type":"integer"},"src":{"description":"The Figma frame address.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"figma"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"figma","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"description":"The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.","properties":{"axisBoundsMax":{"description":"Optional fixed upper bound of the value axis.","type":"number"},"axisBoundsMin":{"description":"Optional fixed lower bound of the value axis.","type":"number"},"baseThresholdColor":{"description":"Optional colour of the values below the first threshold.","type":"string"},"dataSourceId":{"description":"The data source the chart queries, an id from list_data_sources. Required for the block to render.","type":"string"},"decimals":{"description":"Optional number of decimals shown.","type":"number"},"queries":{"description":"The queries charted, in PromQL or SQL as the data source takes. A SQL chart selects a time column aliased \"time\" plus one or more numeric columns, and may use the $__ macros.","items":{"properties":{"legendFormat":{"description":"Optional series label template; may be empty.","type":"string"},"name":{"description":"The query's name.","type":"string"},"query":{"description":"The query itself.","type":"string"}},"required":["name","query"],"type":"object"},"type":"array"},"refreshInterval":{"description":"How often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"thresholds":{"description":"Optional threshold rows, each colouring the values from its value up.","items":{"properties":{"color":{"type":"string"},"label":{"type":"string"},"value":{"type":"number"}},"type":"object"},"type":"array"},"timeRange":{"description":"The window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"title":{"description":"Optional chart title.","type":"string"},"unitCustom":{"description":"The unit label, read when unitType is custom.","type":"string"},"unitType":{"description":"The unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"The chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"width":{"description":"The chart's size.","enum":["compact","standard","wide"],"type":"string"}},"type":"object"},"type":{"const":"metric"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"metric","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The metric blocks it holds.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"metric_grid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"metric_grid","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"inversed":{"description":"Flips the two sides.","type":"boolean"}},"type":"object"},"left":{"description":"The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.","items":{"type":"object"},"minItems":1,"type":"array"},"right":{"description":"The example side. Holds titled_code, metric or mermaid only, and is the one place titled_code is legal.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","left","right"],"title":"split_doc","type":"object"},{"additionalProperties":false,"properties":{"header":{"description":"The section header, as plain text.","type":"string"},"params":{"description":"The rows.","items":{"properties":{"description":{"description":"The description, in minimal markdown.","type":"string"},"name":{"description":"The parameter name, as plain text.","type":"string"},"type":{"description":"The parameter type, as plain text.","type":"string"}},"required":["name"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc_param_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","header","params"],"title":"split_doc_param_list","type":"object"}],"description":"A canonical block. Its type decides which fields it takes."},"block_uid":{"description":"The uid of the block being replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","block"],"type":"object"},"name":"replace_block"},"reset_hook":{"desc":"Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"reset_hook"},"search_documents":{"desc":"Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.","has_params_one_of":true,"json_schema":{"properties":{"limit":{"description":"Maximum number of hits to return. Defaults to 20; cap is 50.","type":"integer"},"query":{"description":"The full-text search query (typo-tolerant, matches block text).","type":"string"}},"required":["query"],"type":"object"},"name":"search_documents"},"unassign_tag":{"desc":"Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"unassign_tag"},"update_block_attrs":{"desc":"Set or override named attributes on an existing block, such as a heading's level, a callout's icon, or a titled_code's title and language. Attributes not mentioned are kept and uid cannot change. Values are validated for the block's type, so a level outside 1 to 3 or a metric width other than compact, standard or wide is rejected. Use replace_block when the type itself has to change. Returns the summary rows of the block and everything nested in it, attrs as they now stand.","has_params_one_of":true,"json_schema":{"properties":{"attrs":{"description":"Attribute keys and values to set (e.g. {\"level\": 2}, {\"icon\": \"lucide:warning\"}).","type":"object"},"block_uid":{"description":"The uid of the block whose attrs should be updated.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","attrs"],"type":"object"},"name":"update_block_attrs"},"update_block_text":{"desc":"Replace the inline text of one text-bearing block: paragraph, heading, code, titled_code, mermaid, a list or task list entry, or a blockquote or callout holding a single paragraph. Type, attrs and uid are kept, so this is the tool for wording changes; an entry keeps the blocks nested under it, and a blockquote or callout holding several blocks is refused, so write to the paragraph you mean. Text follows the canonical markdown subset (**bold**, *italic*, _underline_, ~~strike~~, backtick code, [label](url)), and is plain in a heading and raw in code, titled_code and mermaid. One block is one paragraph; to add a paragraph, insert a block instead. Returns the summary rows of the block as it now stands.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block whose text should be replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"text":{"description":"New inline text in canonical markdown.","type":"string"}},"required":["document_id","branch_id","block_uid","text"],"type":"object"},"name":"update_block_text"},"update_document":{"desc":"Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"icon":{"description":"Optional. The new icon identifier; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"parent_id":{"description":"Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is.","type":"string"}},"required":["document_id"],"type":"object"},"name":"update_document"},"update_hook":{"desc":"Replace the settings of a hook and reset its score and state, returning it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. The hook keeps its type, so settings.type is the type list_hooks reports for it; to change the type, delete the hook and create another.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"The RFC 3339 timestamp at which the reminder is due.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"title":"scheduled-reminder","type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The repository branch to follow.","type":"string"},"paths":{"description":"The repository paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository to follow, by its name alone; the connected GitHub account is its owner.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"title":"github-tracking","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The web address to watch for changes.","type":"string"}},"required":["type","url"],"title":"url-watcher","type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The container image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"title":"container-image-watcher","type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","hook_id","settings"],"type":"object"},"name":"update_hook"},"update_tag":{"desc":"Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color one of the palette names the schema lists; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"Optional. The new colour, one of the palette names; omit to keep the current one.","enum":["red","orange","amber","yellow","lime","green","emerald","teal","cyan","sky","blue","indigo","violet","purple","fuchsia","pink"],"type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"update_tag"}}
\ No newline at end of file
+{"create_document":{"desc":"Create a document and return {document_id, branch_id}, the branch_id being its default branch. It starts with one empty paragraph; fill it with insert_blocks at position end on that branch.","has_params_one_of":true,"json_schema":{"properties":{"icon":{"description":"Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:file-code-fill. Defaults to mingcute:document-2-fill.","type":"string"},"name":{"description":"The display name.","type":"string"},"parent_id":{"description":"Optional. The parent document id; omit it for the organisation root.","type":"string"}},"required":["name"],"type":"object"},"name":"create_document"},"create_hook":{"desc":"Add a freshness hook to one branch of a document and return it as list_hooks would. github-tracking and url-watcher need the deployment's GitHub App and changedetection.io integrations, and are refused without them. A new hook starts at score 100.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"Optional. The id of a block on the branch to anchor the hook to; omit it for the whole document.","type":"string"},"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"When the reminder is due, RFC 3339.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The branch to follow.","type":"string"},"paths":{"description":"The paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository name, owned by the connected GitHub account.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The address to watch.","type":"string"}},"required":["type","url"],"type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","branch_id","settings"],"type":"object"},"name":"create_hook"},"create_tag":{"desc":"Create a tag and return {tag_id}. The name has to be unused in the organisation. The tag lands last in the sidebar order; update_tag moves it and set_tag_assignment puts it on a document.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"The colour.","enum":["red","orange","amber","yellow","lime","green","emerald","teal","cyan","sky","blue","indigo","violet","purple","fuchsia","pink"],"type":"string"},"name":{"description":"The display name.","type":"string"}},"required":["name","color"],"type":"object"},"name":"create_tag"},"delete_block":{"desc":"Delete one block by id, including anything nested inside it. Its comments, hooks and files go with it and cannot be restored, so use move_block when the aim is to reorder and replace_blocks when the aim is new content. Returns {deleted: id}.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The id of the block to delete.","type":"string"},"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"delete_block"},"delete_document":{"desc":"Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"}},"required":["document_id"],"type":"object"},"name":"delete_document"},"delete_hook":{"desc":"Delete a hook, tearing down whatever it watches outside the document. It cannot be restored; update_hook without settings clears what it flagged instead. Returns {hook_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, from list_hooks.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"delete_hook"},"delete_tag":{"desc":"Delete a tag. Every document loses it, on every branch, and it cannot be restored; set_tag_assignment takes it off one document instead. Returns {tag_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"tag_id":{"description":"The tag id, from list_tags or a document's tags.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"delete_tag"},"get_data_source_metadata":{"desc":"Describe what a data source holds: for Prometheus, each metric with its type, help and unit; for SQL, {default_schema, tables: {table: [columns]}}. Read it before writing a query, so the names in it are real ones.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_data_source_metadata"},"get_document":{"desc":"Read one document on one branch: its name, icon, parent_id and updated_at, every branch it has as {id, name, protected, default, updated_at}, the tags on the branch read, and content: its blocks as XML, one element per block with its id, in the format insert_blocks describes. Read a document this way before editing it. A protected branch refuses writes, so write to an unprotected one.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"Optional. Narrows content to this block and what it holds; an id inside a block, such as a search hit's, reads the block holding it.","type":"string"},"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"get_document"},"insert_blocks":{"desc":"Insert blocks into a document, written as XML, all in one call: at its start or end, or before or after the block reference_block_uid names. Every element is a new block, so leave id out; replace_blocks changes existing ones. A block not allowed where it lands is refused, naming what the place takes. Returns the written blocks as XML with their ids.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"content":{"description":"The blocks as XML, one element per block. Elements: \u003cp\u003e; \u003ch1\u003e to \u003ch3\u003e, plain text; \u003cul\u003e and \u003col start\u003e of \u003cli\u003e, and \u003ctasks\u003e of \u003ctask checked\u003e, where an entry holds its text and then any blocks nested under it; \u003cblockquote\u003e and \u003ccallout icon\u003e holding blocks; \u003cpre language\u003e with raw code, or with a title the titled code a split_doc's right side takes; \u003cmermaid\u003e with raw source; \u003chr/\u003e; \u003cimg src alt title width/\u003e; \u003cfigma src width height/\u003e; \u003cmetrics\u003e of \u003cmetric\u003e, which holds a JSON object of its attributes; \u003csplit_doc inversed\u003e holding \u003cleft\u003e, an \u003ch1\u003e then \u003cp\u003e, lists or \u003ccallout\u003e then any \u003cparams header\u003e of \u003cparam name type\u003edescription\u003c/param\u003e, and \u003cright\u003e, a titled \u003cpre\u003e, \u003cmetric\u003e or \u003cmermaid\u003e. Inline text takes \u003cb\u003e, \u003ci\u003e, \u003cu\u003e, \u003cs\u003e, \u003ccode\u003e and \u003ca href\u003e; outside \u003cpre\u003e, \u003cmermaid\u003e and \u003cmetric\u003e, whose content is raw, write \u0026 as \u0026amp; and \u003c as \u0026lt;. Mermaid ends a gantt task name or timeline period at its first colon, so write a colon inside one as #58;. A metric's attributes: dataSourceId (an id from list_data_sources), queries ([{name, query, legendFormat}], PromQL or SQL as the data source takes), refreshInterval (5s|10s|30s|1m|5m|15m|30m|1h|2h|1d), simulationPreset (cpu_usage|memory_usage|disk_usage|http_requests|http_latency|error_rate), timeRange (last_5_minutes|last_15_minutes|last_30_minutes|last_1_hour|last_3_hours|last_6_hours|last_12_hours|last_24_hours|last_2_days|last_7_days|last_30_days|last_90_days|last_6_months|last_1_year|last_2_years|last_5_years|today|yesterday|today_so_far|this_week|this_week_so_far|this_month|this_month_so_far|this_year|this_year_so_far|previous_week|previous_month|previous_year), unitType (custom|nanoseconds|microseconds|milliseconds|seconds|minutes|hours|days|bytes|kilobytes|megabytes|gigabytes|terabytes|bits|kilobits|megabits|gigabits|terabits|percent0to100|percent0to1), visualizationType (line_chart|bar_chart|gauge_chart), width (compact|standard|wide), and optionally title, unitCustom (the label when unitType is custom), decimals, thresholds ([{value, label, color}]), baseThresholdColor, axisBoundsMin, axisBoundsMax.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"position":{"description":"Where the blocks land: before or after the reference block, or at the start or end of the document.","enum":["before","after","start","end"],"type":"string"},"reference_block_uid":{"description":"The id of the block to insert beside. Required with before and after; leave it out with start and end.","type":"string"}},"required":["document_id","branch_id","position","content"],"type":"object"},"name":"insert_blocks"},"list_data_sources":{"desc":"List the organisation's data sources as {id, name, type, status}. type (prometheus, postgresql, mariadb or mysql) decides the query language and which tools serve it. Start here: the other data source tools take an id from this list.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_data_sources"},"list_documents":{"desc":"List the organisation's documents as a tree of {id, name, default_branch_id, children}. Use it to find a document by title; search_documents finds content. parent_id narrows it to that document's children.","has_params_one_of":true,"json_schema":{"properties":{"parent_id":{"description":"Optional. A document whose direct children to list; omit it for the whole tree.","type":"string"}},"type":"object"},"name":"list_documents"},"list_hooks":{"desc":"List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, status, created_at, updated_at}]. A hook watches something outside the document and lowers its score from 100 as that thing changes or a date approaches. block_uid is the block it is anchored to, or null for the whole document.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"list_hooks"},"list_prometheus_labels":{"desc":"List the label names of a Prometheus data source or, with label set, the values that label takes. matchers narrows either to the series they select. Use it to find the labels and values a query should filter on.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start, RFC 3339. Defaults to an hour before to.","type":"string"},"label":{"description":"Optional. A label, such as job, whose values to list instead of the names.","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, such as [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end, RFC 3339. Defaults to now.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"list_prometheus_labels"},"list_prometheus_series":{"desc":"List the series that matchers select in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric has before querying it.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start, RFC 3339. Defaults to an hour before to.","type":"string"},"matchers":{"description":"PromQL series selectors, such as [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end, RFC 3339. Defaults to now.","type":"string"}},"required":["data_source_id","matchers"],"type":"object"},"name":"list_prometheus_series"},"list_tags":{"desc":"List the organisation's tags in sidebar order as [{id, name, color, hidden, documents}]. color is a palette name, or unknown for a colour outside the palette that update_tag can fix. documents are the documents whose default branch carries the tag; a tag on another branch shows only on get_document for that branch. hidden is whether the current user keeps the tag out of their sidebar, and does not stop it being assigned.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_tags"},"move_block":{"desc":"Move a block before or after another block of the same document. It keeps its id and nested content, and with them its comments, hooks and files, which a delete and insert would lose. The landing spot has to accept the block. Returns the moved block as XML.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The id of the block to move.","type":"string"},"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"position":{"description":"Which side of the reference block it lands on.","enum":["before","after"],"type":"string"},"reference_block_uid":{"description":"The id of the block it lands beside.","type":"string"}},"required":["document_id","branch_id","block_uid","position","reference_block_uid"],"type":"object"},"name":"move_block"},"query_data_source":{"desc":"Run a query over a time range: PromQL for Prometheus, read-only SQL for the others, where $__ macros such as $__timeFilter expand against the range. Without chart_type it returns the raw answer, capped at 20 series of 100 points or 200 rows. With chart_type it returns what a metric block would draw: status, series count, and each series' labels, point count and endpoints. Check a query that way before putting it in a block.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. The chart to check the query against; omit it for the values themselves.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start, RFC 3339. Defaults to an hour before to.","type":"string"},"query":{"description":"The PromQL or SQL to run. For a SQL chart, select a time column aliased time and one or more numeric columns.","type":"string"},"to":{"description":"Optional. Range end, RFC 3339. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_data_source"},"read_tool_output":{"desc":"Retrieve the full output of an earlier tool call that was too large to keep in the conversation. Pass the path shown in the truncation notice.","has_params_one_of":true,"json_schema":{"properties":{"file_path":{"description":"The stored output path from the truncation notice.","type":"string"}},"required":["file_path"],"type":"object"},"name":"read_tool_output"},"replace_blocks":{"desc":"Replace one block with the blocks content holds, in the XML insert_blocks describes. To change a block, read it with get_document (block_uid narrows the read), edit the XML and send it back. Keep the id of every element that stays: it keeps its comments, hooks and files, and unchanged content stays exactly as stored. An element without an id is new; an id from outside the replaced block is refused. Returns the written blocks as XML.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The id of the block being replaced.","type":"string"},"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"content":{"description":"The blocks taking its place, as XML in the format insert_blocks describes.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid","content"],"type":"object"},"name":"replace_blocks"},"search_documents":{"desc":"Full-text search for blocks whose text matches the query, across every branch of every document. Returns {documents: [{document_id, document_name, branch_id, branch_name, default, hits: [{block_uid, text}]}]}, best match first. A block a fork copied unchanged is found on every branch holding it. A hit is the innermost block holding the text; get_document with its block_uid reads the element holding it. Use list_documents to find a document by title.","has_params_one_of":true,"json_schema":{"properties":{"limit":{"description":"Optional. The most hits to return: 20 by default, 50 at most.","type":"integer"},"query":{"description":"The text to search for; matching is typo-tolerant.","type":"string"}},"required":["query"],"type":"object"},"name":"search_documents"},"set_tag_assignment":{"desc":"Put a tag on one branch of a document, or take it off. The document is listed under the tag in the sidebar while its default branch carries it. Setting what is already the case succeeds and changes nothing; delete_tag removes a tag everywhere. Returns {document_id, branch_id, tag_id, assigned}.","has_params_one_of":true,"json_schema":{"properties":{"assigned":{"description":"true puts the tag on the branch, false takes it off.","type":"boolean"},"branch_id":{"description":"The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, from list_tags or a document's tags.","type":"string"}},"required":["document_id","branch_id","tag_id","assigned"],"type":"object"},"name":"set_tag_assignment"},"update_document":{"desc":"Change a document's name, icon or parent; give only what changes, and a call changing nothing is refused. Content is untouched, and a parent that is the document itself or one of its descendants is refused. Returns {document_id} with what changed.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"icon":{"description":"Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:rocket-fill.","type":"string"},"name":{"description":"Optional. The new display name.","type":"string"},"parent_id":{"description":"Optional. The new parent document id, or an empty string for the organisation root.","type":"string"}},"required":["document_id"],"type":"object"},"name":"update_document"},"update_hook":{"desc":"Reset a hook's score to 100 and its state to a fresh hook's, and return it as list_hooks would. Without settings the hook keeps them: use that once the document is brought up to date with what the hook flagged. With settings they replace the old ones; the hook keeps its type, so to change it delete the hook and create another.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, from list_hooks.","type":"string"},"settings":{"description":"Optional. New settings, shaped as create_hook's, with the hook's own type.","type":"object"}},"required":["document_id","hook_id"],"type":"object"},"name":"update_hook"},"update_tag":{"desc":"Rename, recolour or move a tag in the sidebar order; give only what changes, and a call changing nothing is refused. A new name has to be unused in the organisation. Returns {tag_id} with what changed. set_tag_assignment changes which documents carry it.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"Optional. The new colour.","enum":["red","orange","amber","yellow","lime","green","emerald","teal","cyan","sky","blue","indigo","violet","purple","fuchsia","pink"],"type":"string"},"name":{"description":"Optional. The new display name.","type":"string"},"sort_index":{"description":"Optional. The 0-based position in list_tags order; 0 is first.","type":"integer"},"tag_id":{"description":"The tag id, from list_tags or a document's tags.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"update_tag"}}
\ No newline at end of file
diff --git a/server/core/internal/assistant/tools/tool.go b/server/core/internal/assistant/tools/tool.go
index 3ee20ae6..72828798 100644
--- a/server/core/internal/assistant/tools/tool.go
+++ b/server/core/internal/assistant/tools/tool.go
@@ -2,16 +2,10 @@ package tools
import (
"context"
- "log/slog"
- "github.com/guregu/null/v5"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
- "github.com/oxynote/oxynote/server/core/internal/assistant/edit"
"github.com/oxynote/oxynote/server/core/internal/datasource"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/hook"
- "github.com/oxynote/oxynote/server/core/internal/document/hook/processor"
- "github.com/oxynote/oxynote/server/core/internal/search"
"github.com/oxynote/oxynote/server/core/internal/tag"
"github.com/rs/xid"
)
@@ -38,6 +32,10 @@ type Info struct {
// Required lists the arguments the model must supply. Empty means
// the schema says nothing about required arguments.
Required []string
+
+ // Traits are the facts that decide how the assistant treats the
+ // tool. The zero value is a plain read.
+ Traits Traits
}
// Schema returns the tool's arguments as a JSON-schema object.
@@ -60,27 +58,6 @@ func (info Info) Schema() map[string]any {
return out
}
-// Available tool access constants.
-const (
- // AccessNone is held by no surface outside this process: the tool
- // addresses conversation state only the assistant has.
- AccessNone Access = "none"
-
- // AccessRead reads the organization's documents.
- AccessRead Access = "read"
-
- // AccessWrite mutates the organization's documents.
- AccessWrite Access = "write"
-
- // AccessDataSource reads one of the organization's outbound
- // data-source connections, which is not the same permission as
- // reading its documents.
- AccessDataSource Access = "data-source"
-)
-
-// Access is the permission a surface must hold to offer a tool.
-type Access string
-
// Traits are the facts about a tool that decide how the assistant
// treats it. A tool states them in one place in its own file rather
// than being interrogated for them from several.
@@ -123,94 +100,37 @@ type Traits struct {
Internal bool
}
-// DestroysContent reports whether a write loses work the caller did not
-// name: an outright removal, or an overwrite that takes nested blocks,
-// uids, comments and hooks with it. Both are what a client deciding
-// whether to confirm first wants to know.
-func (t Traits) DestroysContent() bool {
- return t.Destructive || t.Overwrites
-}
-
-// OpenWorld reports whether the tool reaches outside the organization's
-// own documents: a data-source tool reaches whatever the connection
-// points at.
-func (t Traits) OpenWorld() bool {
- return t.DataSource
-}
-
-// Access reports the permission a surface must hold to offer the tool.
-func (t Traits) Access() Access {
- switch {
- case t.Internal:
- return AccessNone
- case t.DataSource:
- return AccessDataSource
- case t.Write:
- return AccessWrite
- default:
- return AccessRead
- }
-}
-
// Tool is a tool the model can call. What a tool is — whether it
// mutates, whether it survives an approve-all, whether it belongs
-// outside this process — it states in Traits rather than in the set of
-// interfaces it satisfies, so there is one answer to ask and nothing to
-// hold in step.
+// outside this process — it states in its Info's Traits, so there is one
+// answer to ask and nothing to hold in step.
type Tool interface {
- // Info should return the tool's model-facing description.
+ // Info should return the tool's model-facing description and traits.
Info() Info
- // Traits should return the facts that decide how the assistant
- // treats this tool.
- Traits() Traits
-
- // Title should return a short line describing what the tool is
- // about to do, or an empty string for tools too noisy or too
- // generic to announce, and reject arguments it cannot read.
- Title(inp DescribeInput) (string, error)
-
- // Summary should describe the change the tool is proposing, for
- // the card asking the user to approve it, without performing it. It
- // should reject arguments it cannot read: a payload Execute would
- // refuse is not worth asking the user to approve.
- //
- // Only a tool whose Traits report a write is ever asked; the rest
- // embed plainSummary.
- Summary(inp DescribeInput) (ActionSummary, error)
-
// Execute should perform the tool's work and return the result
- // serialised for the model.
- Execute(inp Input) (string, error)
+ // serialised for the model. inp is the call's arguments together
+ // with every read and write the tools share, scoped to the session's
+ // organisation.
+ Execute(inp *input) (string, error)
}
-// plainSummary implements the Tool interface to avoid Summary
-// re-implementation in tools that propose nothing.
-type plainSummary struct{}
-
-// Summary describes nothing: a tool that does not write is never gated,
-// so it is never asked what it is about to change.
-func (plainSummary) Summary(_ DescribeInput) (ActionSummary, error) {
- return ActionSummary{}, nil
+// titler is a read that announces itself while it runs. A read without
+// a title runs silently; a write is announced by its summary.
+type titler interface {
+ // Title should return a short line describing what the tool is
+ // about to do, or an empty string when its arguments name nothing it
+ // can describe.
+ Title(inp DescribeInput) string
}
-// plainTraits implements the Tool interface to avoid Traits
-// re-implementation in tools that are plain reads.
-type plainTraits struct{}
-
-// Traits reports a plain read: no write, no outbound connection, nothing
-// internal.
-func (plainTraits) Traits() Traits {
- return Traits{}
-}
-
-// plainTitle implements the Tool interface to avoid Title
-// re-implementation in tools too generic to announce.
-type plainTitle struct{}
-
-// Title returns no status line.
-func (plainTitle) Title(_ DescribeInput) (string, error) {
- return "", nil
+// summarizer is a write that describes the change it proposes, for the
+// card asking the user to approve it. Every write is one.
+type summarizer interface {
+ // Summary should describe the change without performing it, and
+ // reject arguments it cannot read: a payload Execute would refuse is
+ // not worth asking the user to approve.
+ Summary(inp DescribeInput) (ActionSummary, error)
}
// Args is one tool call's decoded arguments. Validate is what Decode
@@ -237,7 +157,7 @@ type DescribeInput interface {
// Decode should decode the call's arguments into dst and validate
// them, reporting a payload that is malformed or incomplete as an
- // error naming the tool. It is the only way into a call's
+ // error. It is the only way into a call's
// arguments: a description that cannot read them says so rather
// than describing a call from zero values.
Decode(dst Args) error
@@ -268,226 +188,6 @@ type DescribeInput interface {
FetchHook(documentID, hookID xid.ID) (*hook.Hook, error)
}
-// DataSources is the organisation's outbound data-source connections as
-// a tool reads them. Every method is already scoped to the session's
-// organisation, so a tool can never reach another one's connections.
-type DataSources interface {
- // FetchDataSources should return every data source the organisation
- // owns.
- FetchDataSources() ([]datasource.DataSource, error)
-
- // DataSourceRunner should return the runner that operates the data
- // source the id names, or an error when the organisation owns no
- // data source with that id.
- DataSourceRunner(id xid.ID) (datasource.Runner, error)
-
- // CheckDataSources should refuse a write naming a data source the
- // organisation does not own.
- CheckDataSources(ids []string) error
-}
-
-// Documents is the organisation's document tree as a tool reads it.
-// Every method is already scoped to the session's organisation, so a
-// tool can never reach another one's documents.
-type Documents interface {
- // FetchDocument should return the document on its default branch.
- FetchDocument(documentID xid.ID) (*document.Document, error)
-
- // FetchBranch should return the document on the branch branchID names,
- // refusing a branch the document does not have.
- FetchBranch(documentID, branchID xid.ID) (*document.Document, error)
-
- // FetchDocumentContent should return the parsed content of the branch
- // branchID names, for the ops that only need the block tree.
- FetchDocumentContent(documentID, branchID xid.ID) (document.Content, error)
-
- // FetchDocumentBlock should return the block blockUID names on the
- // branch, refusing a uid the branch does not hold.
- FetchDocumentBlock(documentID, branchID xid.ID, blockUID string) (document.Block, error)
-
- // FetchDocumentBranches should list every branch of the document,
- // refusing a document the organisation does not have.
- FetchDocumentBranches(documentID xid.ID) ([]document.BranchSummary, error)
-
- // FetchDocumentTree should return every document in the organisation as
- // a nested summary tree.
- FetchDocumentTree() (document.Summaries, error)
-
- // FetchDocumentChildren should return the direct children of parentID; a
- // null parent means the organisation's root.
- FetchDocumentChildren(parentID null.Value[xid.ID]) (document.Summaries, error)
-}
-
-// DocumentWriter changes the shape of the document tree and announces
-// what it changed, so connected sidebars stay in step.
-type DocumentWriter interface {
- // CreateDocument should insert the document together with its first
- // maintainer and its search job, atomically, refusing a parent that
- // does not exist.
- CreateDocument(doc document.Document) error
-
- // DeleteDocument should remove the document.
- DeleteDocument(id xid.ID) error
-
- // MoveDocument should re-parent the document doc describes, refusing
- // a parent that does not exist or that would put the document under
- // itself.
- MoveDocument(doc *document.Document, parentID null.Value[xid.ID]) error
-
- // NotifyTreeChange should tell subscribers that the tree under
- // parentID changed.
- NotifyTreeChange(parentID null.Value[xid.ID])
-
- // NotifyTreeChangeForDocument should look up the document's current
- // parent and fire a tree-change for it, for the ops whose arguments
- // carry no parent.
- NotifyTreeChangeForDocument(documentID xid.ID)
-}
-
-// Tags is the organisation's tags as a tool reads them. Every method is
-// already scoped to the session's organisation.
-type Tags interface {
- // FetchTagTree should return every tag in the organisation in display
- // order, each with the documents whose default branch carries it and
- // whether the session's user hides it.
- FetchTagTree() (tag.Summaries, error)
-
- // FetchBranchTags should return the tags the branch branchID names
- // carries, in the tags' display order.
- FetchBranchTags(documentID, branchID xid.ID) ([]tag.Tag, error)
-}
-
-// TagWriter changes the organisation's tags and what carries them, and
-// announces the change so connected sidebars stay in step.
-type TagWriter interface {
- // CreateTag should insert the tag at the end of the organisation's
- // tags, refusing a name the organisation already uses.
- CreateTag(t tag.Tag) error
-
- // UpdateTag should rename and/or recolour the tag the id names,
- // refusing an id that names nothing.
- UpdateTag(tagID xid.ID, inp tag.UpdateInput) error
-
- // DeleteTag should remove the tag the id names and every assignment
- // of it, refusing an id that names nothing.
- DeleteTag(tagID xid.ID) error
-
- // AssignTag should make the branch branchID names carry the tag,
- // refusing a document, branch or tag that does not exist.
- AssignTag(documentID, branchID, tagID xid.ID) error
-
- // UnassignTag should stop the branch branchID names carrying the
- // tag, refusing a document, branch or tag that does not exist.
- UnassignTag(documentID, branchID, tagID xid.ID) error
-
- // MoveTag should move the tag to the 0-based position among the
- // organisation's tags, refusing a position outside them.
- MoveTag(tagID xid.ID, sortIndex int) error
-
- // NotifyTagTreeChange should tell subscribers that the tag tree
- // changed.
- NotifyTagTreeChange()
-
- // NotifyBranchTagsChange should tell the document's subscribers that
- // the tags the branch branchID names carries changed, so an open
- // header refreshes its pills.
- NotifyBranchTagsChange(documentID, branchID xid.ID)
-}
-
-// Hooks is the freshness hooks on a document's branches as a tool reads
-// them. Every method is already scoped to the session's organisation.
-type Hooks interface {
- // FetchHooks should return every hook on the branch branchID names,
- // refusing a branch the document does not have.
- FetchHooks(documentID, branchID xid.ID) ([]hook.Hook, error)
-}
-
-// HookWriter changes the hooks on a document's branches and announces
-// the change so an open editor stays in step.
-type HookWriter interface {
- // CreateHook should create a hook of the type with the settings on
- // the branch branchID names, anchored to the block blockUID names
- // when one is given, refusing a branch or block that does not exist
- // and a type whose integration the deployment lacks.
- CreateHook(documentID, branchID xid.ID, blockUID string, t hook.Type, settings processor.Settings) (*hook.Hook, error)
-
- // UpdateHook should replace the hook's settings and reset its score
- // and state.
- UpdateHook(hk *hook.Hook, settings processor.Settings) error
-
- // ResetHook should restore the hook's score and state, keeping its
- // settings.
- ResetHook(hk *hook.Hook) error
-
- // DeleteHook should tear down what the hook holds outside the
- // document and remove it.
- DeleteHook(hk *hook.Hook) error
-}
-
-// Editor applies content changes to a live document through the
-// realtime service, so connected editors see them as they land.
-type Editor interface {
- // ApplyEdit should ship an operation batch to the realtime service
- // for the branch branchID names of the document, reporting any
- // operation it refused.
- ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) error
-
- // ValidatePlacement should check that a block is legal next to, or
- // in place of, the block referenceUID names on the branch.
- ValidatePlacement(documentID, branchID xid.ID, referenceUID string, b block.Block) error
-
- // ValidateAttrUpdate should check that setting attrs on the block
- // blockUID names on the branch leaves it with attributes its type
- // allows.
- ValidateAttrUpdate(documentID, branchID xid.ID, blockUID string, attrs map[string]any) error
-
- // ValidateMove should check that the block blockUID names may sit
- // where a move relative to referenceUID would put it on the branch.
- ValidateMove(documentID, branchID xid.ID, blockUID, referenceUID string) error
-}
-
-// Input is everything a tool needs to do its work: the call's arguments
-// and context, plus the organisation's documents and the services that
-// change them.
-//
-// It is a facade over the narrower surfaces above rather than a list of
-// its own, and it is built per call, so a tool holds no state between
-// them.
-//
-//nolint:interfacebloat // facade composed of the narrower surfaces above
-type Input interface {
- DescribeInput
- Documents
- DocumentWriter
- DataSources
- Tags
- TagWriter
- Hooks
- HookWriter
- Editor
-
- // OrganizationID should return the organisation every call is
- // scoped to, for the tools that stamp it onto a new row.
- OrganizationID() string
-
- // UserID should return the user the assistant is acting for, for
- // the tools that record who acted.
- UserID() string
-
- // SearchBlocks should return blocks whose text matches the query,
- // capped at limit hits.
- SearchBlocks(query string, limit int) ([]search.Block, error)
-
- // ReadOffloaded should return a tool result that was moved out of
- // the conversation for size.
- ReadOffloaded(path string) (string, error)
-
- // Warn should record something a tool decided to carry on through,
- // so a degraded result is diagnosable without re-running the
- // conversation.
- Warn(msg string, attrs ...slog.Attr)
-}
-
// OffloadReader is the retrieval half of the store holding tool results
// that were too large to keep in the conversation. The persist
// package's Offload satisfies it.
diff --git a/server/core/internal/assistant/tools/tool_test.go b/server/core/internal/assistant/tools/tool_test.go
deleted file mode 100644
index 7ea60217..00000000
--- a/server/core/internal/assistant/tools/tool_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package tools
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func Test_plainSummary_Summary(t *testing.T) {
- t.Parallel()
-
- // a tool that proposes nothing describes nothing, and never fails
- // doing so: the gate is only ever applied to a write.
- got, err := plainSummary{}.Summary(testInput(testDeps(nil, nil, nil), NameGetDocument, `{`))
- require.NoError(t, err)
- assert.Equal(t, ActionSummary{}, got)
-}
-
-func Test_plainTraits_Traits(t *testing.T) {
- t.Parallel()
-
- // a plain read writes nothing, reaches no outbound connection and
- // belongs on every surface.
- assert.Equal(t, Traits{}, plainTraits{}.Traits())
-}
-
-func Test_Traits_DestroysContent(t *testing.T) {
- cc := map[string]struct {
- Traits Traits
- Result bool
- }{
- "Read": {},
- "Plain write": {Traits: Traits{Write: true}},
- "Destructive": {Traits: Traits{Write: true, Destructive: true}, Result: true},
- "Overwriting": {Traits: Traits{Write: true, Overwrites: true}, Result: true},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, c.Traits.DestroysContent())
- })
- }
-}
-
-func Test_Traits_OpenWorld(t *testing.T) {
- t.Parallel()
-
- assert.False(t, Traits{}.OpenWorld())
- assert.True(t, Traits{DataSource: true}.OpenWorld())
-}
-
-func Test_Traits_Access(t *testing.T) {
- cc := map[string]struct {
- Traits Traits
- Result Access
- }{
- "Read": {Result: AccessRead},
- "Write": {Traits: Traits{Write: true}, Result: AccessWrite},
- "Data source": {Traits: Traits{DataSource: true}, Result: AccessDataSource},
- "Internal": {Traits: Traits{Internal: true}, Result: AccessNone},
- "Internal write": {Traits: Traits{Write: true, Internal: true}, Result: AccessNone},
- "Data source over write": {Traits: Traits{Write: true, DataSource: true}, Result: AccessDataSource},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, c.Traits.Access())
- })
- }
-}
-
-func Test_plainTitle_Title(t *testing.T) {
- t.Parallel()
-
- // a tool too generic to announce says nothing, and never fails
- // doing so: the arguments are not even read.
- got, err := plainTitle{}.Title(testInput(testDeps(nil, nil, nil), NameListDocuments, `{`))
- require.NoError(t, err)
- assert.Empty(t, got)
-}
diff --git a/server/core/internal/assistant/tools/tools.go b/server/core/internal/assistant/tools/tools.go
index 881f7e94..957bb42b 100644
--- a/server/core/internal/assistant/tools/tools.go
+++ b/server/core/internal/assistant/tools/tools.go
@@ -12,7 +12,6 @@ import (
"slices"
"github.com/cloudwego/eino/components/tool"
- "github.com/rs/xid"
)
// Name is the canonical identifier of one tool the assistant
@@ -26,7 +25,6 @@ type Name string
const (
NameListDocuments Name = "list_documents"
NameGetDocument Name = "get_document"
- NameReadBlock Name = "read_block"
NameSearchDocuments Name = "search_documents"
NameListTags Name = "list_tags"
NameListHooks Name = "list_hooks"
@@ -37,15 +35,11 @@ const (
// gates by scope asks Traits.DataSource rather than lumping them in
// with the document reads.
const (
- NameListDataSources Name = "list_data_sources"
- NameGetPrometheusMetadata Name = "get_prometheus_metadata"
- NameListPrometheusLabelNames Name = "list_prometheus_label_names"
- NameListPrometheusLabelValues Name = "list_prometheus_label_values"
- NameListPrometheusSeries Name = "list_prometheus_series"
- NameQueryPrometheus Name = "query_prometheus"
- NameGetSQLMetadata Name = "get_sql_metadata"
- NameGetSQLQueryLabels Name = "get_sql_query_labels"
- NameQuerySQL Name = "query_sql"
+ NameListDataSources Name = "list_data_sources"
+ NameGetDataSourceMetadata Name = "get_data_source_metadata"
+ NameListPrometheusLabels Name = "list_prometheus_labels"
+ NameListPrometheusSeries Name = "list_prometheus_series"
+ NameQueryDataSource Name = "query_data_source"
)
// Write tools — always confirmed. Deletes are the most
@@ -54,21 +48,16 @@ const (
NameCreateDocument Name = "create_document"
NameDeleteDocument Name = "delete_document"
NameUpdateDocument Name = "update_document"
- NameInsertBlock Name = "insert_block"
- NameReplaceBlock Name = "replace_block"
- NameUpdateBlockText Name = "update_block_text"
- NameUpdateBlockAttrs Name = "update_block_attrs"
+ NameInsertBlocks Name = "insert_blocks"
+ NameReplaceBlocks Name = "replace_blocks"
NameDeleteBlock Name = "delete_block"
NameMoveBlock Name = "move_block"
NameCreateTag Name = "create_tag"
NameUpdateTag Name = "update_tag"
NameDeleteTag Name = "delete_tag"
- NameAssignTag Name = "assign_tag"
- NameUnassignTag Name = "unassign_tag"
- NameMoveTag Name = "move_tag"
+ NameSetTagAssignment Name = "set_tag_assignment"
NameCreateHook Name = "create_hook"
NameUpdateHook Name = "update_hook"
- NameResetHook Name = "reset_hook"
NameDeleteHook Name = "delete_hook"
)
@@ -103,38 +92,28 @@ func New(deps *Deps) *Set {
all := []Tool{
listDocuments{},
getDocument{},
- readBlock{},
listTags{},
listHooks{},
searchDocuments{},
listDataSources{},
- getPrometheusMetadata{},
- listPrometheusLabelNames{},
- listPrometheusLabelValues{},
+ getDataSourceMetadata{},
+ listPrometheusLabels{},
listPrometheusSeries{},
- queryPrometheus{},
- getSQLMetadata{},
- getSQLQueryLabels{},
- querySQL{},
+ queryDataSource{},
createDocument{},
deleteDocument{},
updateDocument{},
- insertBlock{},
- replaceBlock{},
- updateBlockText{},
- updateBlockAttrs{},
+ insertBlocks{},
+ replaceBlocks{},
deleteBlock{},
moveBlock{},
createTag{},
updateTag{},
deleteTag{},
- assignTag{},
- unassignTag{},
- moveTag{},
+ setTagAssignment{},
createHook{},
updateHook{},
- resetHook{},
deleteHook{},
readToolOutput{},
@@ -144,13 +123,11 @@ func New(deps *Deps) *Set {
for _, tl := range all {
et := newEinoTool(tl, deps)
- tr := tl.Traits()
+ tr := et.info.Traits
s.entries = append(s.entries, Entry{
- Traits: tr,
- Name: et.info.Name,
- Info: et.info,
- Tool: et,
+ Info: et.info,
+ Tool: et,
})
var it registryTool = et
@@ -171,18 +148,12 @@ func New(deps *Deps) *Set {
return s
}
-// Entry describes one registered tool: what kind of tool it is, its
-// name, how it describes itself, and its implementation with no
-// confirmation gate applied.
+// Entry describes one registered tool: how it describes itself, and its
+// implementation with no confirmation gate applied.
type Entry struct {
- Traits
-
- // Name is the tool's canonical identifier.
- Name Name
-
- // Info is the tool's own description. A surface that has to
- // announce the tool builds its description from this, so no surface
- // has to restate a schema the tool already owns.
+ // Info is the tool's own description and traits. A surface that has
+ // to announce the tool builds its description from this, so no
+ // surface has to restate a schema the tool already owns.
Info Info
// Tool is the tool without its confirmation gate. Running it
@@ -205,7 +176,7 @@ func (s *Set) Entries() []Entry {
// addressed.
func (s *Set) Entry(name Name) (Entry, bool) {
for _, e := range s.entries {
- if e.Name == name {
+ if e.Info.Name == name {
return e, true
}
}
@@ -240,12 +211,7 @@ func (s *Set) Label(ctx context.Context, name Name, args json.RawMessage) string
return ""
}
- label, err := t.Title(ctx, args)
- if err != nil {
- return ""
- }
-
- return label
+ return t.Title(ctx, args)
}
// WriteNames returns every tool that mutates a document.
@@ -258,41 +224,14 @@ func (s *Set) WriteNames() []string {
return slices.Clone(s.writes)
}
-// Result is one tool call's outcome: the payload the model reads, and
-// the documents the call changed.
-type Result struct {
- // Output is the tool's result, serialised for the caller.
- Output string
-
- // Documents lists the branches this call created or changed, in
- // the order it touched them.
- //
- // It is recorded by the Input as the call mutates rather than read
- // back out of the arguments, so it is right for a call that changes
- // several documents and empty for one that changes none — neither
- // of which an argument can be asked about. A delete records
- // nothing: the document it names no longer exists to point at.
- Documents []Touched
-}
-
-// Touched names one branch of a document a call changed, which is what
-// a surface needs to link back to it.
-type Touched struct {
- // DocumentID is the document changed.
- DocumentID xid.ID
-
- // BranchID is the branch of it the change landed on.
- BranchID xid.ID
-}
-
// Runner runs one tool call in this package's own vocabulary: the raw
// JSON arguments in, the outcome out. Surfaces outside this package
// reach a tool through it, so none of them has to speak the agent
// framework's interface to run one.
type Runner interface {
- // Run should perform the call and report what it produced and what
- // it changed.
- Run(ctx context.Context, args json.RawMessage) (Result, error)
+ // Run should perform the call and return its result serialised for
+ // the caller.
+ Run(ctx context.Context, args json.RawMessage) (string, error)
}
// registryTool is what the registry stores: a tool the agent can invoke
@@ -305,5 +244,5 @@ type registryTool interface {
// Title should return a short line describing what the tool is
// about to do, or an empty string for tools too noisy or too
// generic to announce.
- Title(ctx context.Context, args json.RawMessage) (string, error)
+ Title(ctx context.Context, args json.RawMessage) string
}
diff --git a/server/core/internal/assistant/tools/tools_test.go b/server/core/internal/assistant/tools/tools_test.go
index ecb3a6d3..c28daae3 100644
--- a/server/core/internal/assistant/tools/tools_test.go
+++ b/server/core/internal/assistant/tools/tools_test.go
@@ -19,37 +19,27 @@ func allToolNames() []Name {
return []Name{
NameListDocuments,
NameGetDocument,
- NameReadBlock,
NameListTags,
NameListHooks,
NameSearchDocuments,
NameListDataSources,
- NameGetPrometheusMetadata,
- NameListPrometheusLabelNames,
- NameListPrometheusLabelValues,
+ NameGetDataSourceMetadata,
+ NameListPrometheusLabels,
NameListPrometheusSeries,
- NameQueryPrometheus,
- NameGetSQLMetadata,
- NameGetSQLQueryLabels,
- NameQuerySQL,
+ NameQueryDataSource,
NameCreateDocument,
NameDeleteDocument,
NameUpdateDocument,
- NameInsertBlock,
- NameReplaceBlock,
- NameUpdateBlockText,
- NameUpdateBlockAttrs,
+ NameInsertBlocks,
+ NameReplaceBlocks,
NameDeleteBlock,
NameMoveBlock,
NameCreateTag,
NameUpdateTag,
NameDeleteTag,
- NameAssignTag,
- NameUnassignTag,
- NameMoveTag,
+ NameSetTagAssignment,
NameCreateHook,
NameUpdateHook,
- NameResetHook,
NameDeleteHook,
NameReadToolOutput,
}
@@ -89,16 +79,19 @@ func Test_New(t *testing.T) {
assert.Equal(t, string(name), info.Name)
tl := adapter(t, it).tl
- tr := tl.Traits()
+ tr := tl.Info().Traits
// a write has to describe what it proposes, and nothing else
- // has anything to propose. Every tool satisfies the interface,
- // so this asks what a compiler cannot: that the summary is
- // actually there.
- sum, serr := tl.Summary(testInput(testDeps(stubDocumentDB(), nil, nil), name, requiredArgs(t, name)))
- require.NoError(t, serr)
- assert.Equal(t, tr.Write, sum.Summary != "",
- "%s declares Write=%v but Summary=%q", name, tr.Write, sum.Summary)
+ // has anything to propose: this asks what a compiler cannot,
+ // that every write's summary is actually there.
+ s, summarizes := tl.(summarizer)
+ require.Equal(t, tr.Write, summarizes, "%s declares Write=%v but summarizer=%v", name, tr.Write, summarizes)
+
+ if summarizes {
+ sum, serr := s.Summary(testInput(testDeps(stubContentDB(nil), nil, nil), name, requiredArgs(t, name)))
+ require.NoError(t, serr)
+ assert.NotEmpty(t, sum.Summary, "%s has an empty summary", name)
+ }
// a destructive tool is a write first; nothing else can be
// destructive.
@@ -152,11 +145,7 @@ func Test_Set_Entries(t *testing.T) {
destructive := []Name{NameDeleteDocument, NameDeleteBlock, NameDeleteTag, NameDeleteHook}
for i, e := range entries {
- assert.Equal(t, allToolNames()[i], e.Name)
-
- // the entry carries the tool's own description, so a surface
- // outside this package never has to ask the tool for it.
- assert.Equal(t, e.Name, e.Info.Name)
+ assert.Equal(t, allToolNames()[i], e.Info.Name)
// every model-facing string follows the rules a description keeps
// on both surfaces: it says nothing about a confirmation flow,
@@ -192,26 +181,27 @@ func Test_Set_Entries(t *testing.T) {
walk(e.Info.Properties, "")
for path, text := range texts {
- assert.NotEmpty(t, text, "%s: %s has no description", e.Name, path)
+ assert.NotEmpty(t, text, "%s: %s has no description", e.Info.Name, path)
for _, banned := range []string{"confirm", "approv", "\u2014", "organization"} {
- assert.NotContains(t, strings.ToLower(text), banned, "%s: %s", e.Name, path)
+ assert.NotContains(t, strings.ToLower(text), banned, "%s: %s", e.Info.Name, path)
}
}
// the entry carries the tool without its confirmation gate,
// while the registry keeps the gated one for the chat loop.
_, gated := e.Tool.(*confirming)
- assert.False(t, gated, "%s entry must be ungated", e.Name)
+ assert.False(t, gated, "%s entry must be ungated", e.Info.Name)
- assert.Equal(t, slices.Contains(s.WriteNames(), string(e.Name)), e.Write, "%s write flag", e.Name)
- assert.Equal(t, slices.Contains(destructive, e.Name), e.Destructive, "%s destructive flag", e.Name)
- assert.Equal(t, e.Name == NameReadToolOutput, e.Internal, "%s internal flag", e.Name)
+ tr := e.Info.Traits
+ assert.Equal(t, slices.Contains(s.WriteNames(), string(e.Info.Name)), tr.Write, "%s write flag", e.Info.Name)
+ assert.Equal(t, slices.Contains(destructive, e.Info.Name), tr.Destructive, "%s destructive flag", e.Info.Name)
+ assert.Equal(t, e.Info.Name == NameReadToolOutput, tr.Internal, "%s internal flag", e.Info.Name)
}
// mutating the returned slice must not affect the registry.
- entries[0].Name = "clobbered"
- assert.Equal(t, allToolNames()[0], s.Entries()[0].Name)
+ entries[0].Info.Name = "clobbered"
+ assert.Equal(t, allToolNames()[0], s.Entries()[0].Info.Name)
}
func Test_Set_Entry(t *testing.T) {
@@ -258,10 +248,10 @@ func Test_Set_Entry(t *testing.T) {
return
}
- assert.Equal(t, c.Result, e.Name)
+ assert.Equal(t, c.Result, e.Info.Name)
_, gated := e.Tool.(*confirming)
- assert.False(t, gated, "%s entry must be ungated", e.Name)
+ assert.False(t, gated, "%s entry must be ungated", e.Info.Name)
})
}
}
@@ -279,21 +269,16 @@ func Test_Set_WriteNames(t *testing.T) {
string(NameCreateDocument),
string(NameDeleteDocument),
string(NameUpdateDocument),
- string(NameInsertBlock),
- string(NameReplaceBlock),
- string(NameUpdateBlockText),
- string(NameUpdateBlockAttrs),
+ string(NameInsertBlocks),
+ string(NameReplaceBlocks),
string(NameDeleteBlock),
string(NameMoveBlock),
string(NameCreateTag),
string(NameUpdateTag),
string(NameDeleteTag),
- string(NameAssignTag),
- string(NameUnassignTag),
- string(NameMoveTag),
+ string(NameSetTagAssignment),
string(NameCreateHook),
string(NameUpdateHook),
- string(NameResetHook),
string(NameDeleteHook),
}, got)
@@ -325,15 +310,17 @@ func Test_Set_Label(t *testing.T) {
Args: `{` + targetArgs(_stubMainBranchID) + `}`,
Result: "Reading Runbook",
},
- "Write names the document": {
- Name: NameUpdateBlockText,
- Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"b","text":"t"}`,
- Result: "Updating Runbook",
+ // a write is announced by the summary its approval card shows.
+ "Write is announced by its summary": {
+ DB: stubContentDB(nil),
+ Name: NameReplaceBlocks,
+ Args: `{` + targetArgs(_stubMainBranchID) + `,"block_uid":"a","content":"t
"}`,
+ Result: "Replace a block in Runbook with 1 block",
},
- "Tag write names the tag": {
+ "Tag write is announced by its summary": {
Name: NameDeleteTag,
Args: `{"tag_id":"` + _testTagID.String() + `"}`,
- Result: "Deleting tag Production",
+ Result: "Delete tag Production and take it off the 1 page carrying it",
},
"Unresolvable document is not announced": {
DB: &DBMock{
diff --git a/server/core/internal/assistant/tools/util.go b/server/core/internal/assistant/tools/util.go
index 790f50c1..1db21ba5 100644
--- a/server/core/internal/assistant/tools/util.go
+++ b/server/core/internal/assistant/tools/util.go
@@ -1,19 +1,76 @@
package tools
import (
+ "bytes"
"encoding/json"
"fmt"
+ "strings"
+)
+
+// Descriptions of the arguments several tools share, so each is worded
+// once.
+const (
+ // _documentIDDescription describes a document_id argument.
+ _documentIDDescription = "The document id."
+
+ // _branchIDDescription describes a branch_id argument.
+ _branchIDDescription = "The branch id, from list_documents, search_documents or get_document. A protected branch refuses writes."
+
+ // _dataSourceIDDescription describes a data_source_id argument.
+ _dataSourceIDDescription = "The data source id, from list_data_sources."
+
+ // _tagIDDescription describes a tag_id argument.
+ _tagIDDescription = "The tag id, from list_tags or a document's tags."
+
+ // _hookIDDescription describes a hook_id argument.
+ _hookIDDescription = "The hook id, from list_hooks."
+
+ // _fromDescription describes the start of a time range.
+ _fromDescription = "Optional. Range start, RFC 3339. Defaults to an hour before to."
+
+ // _toDescription describes the end of a time range.
+ _toDescription = "Optional. Range end, RFC 3339. Defaults to now."
+
+ // _matchersDescription describes a list of PromQL series selectors.
+ _matchersDescription = `PromQL series selectors, such as ["up", "{job=\"api\"}"].`
)
// result is the single place tool result envelopes are serialised;
-// centralising it keeps the JSON shape consistent.
+// centralising it keeps the JSON shape consistent. <, > and & stay as
+// they are: escaped, they cost the model six characters each, and
+// document content is full of them.
func result(v any) (string, error) {
- data, err := json.Marshal(v)
- if err != nil {
+ var buf bytes.Buffer
+
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+
+ if err := enc.Encode(v); err != nil {
return "", fmt.Errorf("marshalling tool result: %w", err)
}
- return string(data), nil
+ return strings.TrimSuffix(buf.String(), "\n"), nil
+}
+
+// countPhrase phrases a count of the noun for a summary, such as
+// "1 page" or "3 pages".
+func countPhrase(n int, noun string) string {
+ if n == 1 {
+ return "1 " + noun
+ }
+
+ return fmt.Sprintf("%d %ss", n, noun)
+}
+
+// sentence joins clauses as "A, b and c", with the first letter
+// capitalised.
+func sentence(clauses []string) string {
+ s := clauses[len(clauses)-1]
+ if len(clauses) > 1 {
+ s = strings.Join(clauses[:len(clauses)-1], ", ") + " and " + s
+ }
+
+ return strings.ToUpper(s[:1]) + s[1:]
}
// errRequired reports an argument the tool cannot act without.
diff --git a/server/core/internal/assistant/tools/util_test.go b/server/core/internal/assistant/tools/util_test.go
index cdbde204..ca66a9cb 100644
--- a/server/core/internal/assistant/tools/util_test.go
+++ b/server/core/internal/assistant/tools/util_test.go
@@ -25,3 +25,40 @@ func Test_errRequired(t *testing.T) {
assert.EqualError(t, errRequired("document_id"), "document_id is required")
}
+
+func Test_countPhrase(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "1 page", countPhrase(1, "page"))
+ assert.Equal(t, "3 blocks", countPhrase(3, "block"))
+}
+
+func Test_sentence(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Clauses []string
+ Result string
+ }{
+ "One clause": {
+ Clauses: []string{"rename it"},
+ Result: "Rename it",
+ },
+ "Two clauses": {
+ Clauses: []string{"rename it", "move it"},
+ Result: "Rename it and move it",
+ },
+ "Three clauses": {
+ Clauses: []string{"rename it", "recolour it", "move it"},
+ Result: "Rename it, recolour it and move it",
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, sentence(c.Clauses))
+ })
+ }
+}
diff --git a/server/core/internal/assistant/turn_test.go b/server/core/internal/assistant/turn_test.go
index a69b1d54..74a47e45 100644
--- a/server/core/internal/assistant/turn_test.go
+++ b/server/core/internal/assistant/turn_test.go
@@ -232,7 +232,7 @@ func Test_turn_relayMessage(t *testing.T) {
},
"Narration before a tool call is transient": {
Msg: schema.AssistantMessage("let me look", []schema.ToolCall{
- {ID: "1", Function: schema.FunctionCall{Name: "read_block"}},
+ {ID: "1", Function: schema.FunctionCall{Name: "get_document"}},
}),
Result: "let me look",
Kind: protocol.TextEndKindStatus,
@@ -287,7 +287,7 @@ func Test_turn_relayStream(t *testing.T) {
"Narration before a tool call is transient": {
Stream: schema.StreamReaderFromArray([]*schema.Message{
schema.AssistantMessage("let me look", []schema.ToolCall{
- {ID: "1", Function: schema.FunctionCall{Name: "read_block"}},
+ {ID: "1", Function: schema.FunctionCall{Name: "get_document"}},
}),
}),
Result: "let me look",
@@ -379,7 +379,7 @@ func Test_turn_confirmation(t *testing.T) {
InterruptContexts: []*adk.InterruptCtx{{
ID: "i1",
Info: tools.ActionSummary{
- Tool: tools.NameUpdateBlockText,
+ Tool: tools.NameReplaceBlocks,
DocumentID: docID,
DocumentName: "Runbook",
Summary: "Reword the intro",
@@ -388,7 +388,7 @@ func Test_turn_confirmation(t *testing.T) {
},
IDs: []string{"i1"},
Actions: []protocol.ConfirmAction{{
- Tool: string(tools.NameUpdateBlockText),
+ Tool: string(tools.NameReplaceBlocks),
DocumentID: docID.String(),
DocumentName: "Runbook",
Summary: "Reword the intro",
diff --git a/server/core/internal/buildinfo/buildinfo.go b/server/core/internal/buildinfo/buildinfo.go
index 9aed3d2e..d9a23467 100644
--- a/server/core/internal/buildinfo/buildinfo.go
+++ b/server/core/internal/buildinfo/buildinfo.go
@@ -78,17 +78,6 @@ type BuildInfo struct {
Timestamp time.Time `json:"timestamp"`
}
-// String turns build information into a string.
-func (b BuildInfo) String() string {
- return fmt.Sprintf(
- "%s %s (%s; %s)",
- b.Name,
- b.Version,
- b.Commit,
- b.FormattedTimestamp(),
- )
-}
-
// VersionName extracts the name of the version from semver build
// metadata.
func (b BuildInfo) VersionName() string {
diff --git a/server/core/internal/buildinfo/buildinfo_test.go b/server/core/internal/buildinfo/buildinfo_test.go
index a3e7ccf7..2c4f69d3 100644
--- a/server/core/internal/buildinfo/buildinfo_test.go
+++ b/server/core/internal/buildinfo/buildinfo_test.go
@@ -52,23 +52,6 @@ func Test_parseBuildValues(t *testing.T) {
}
}
-func Test_BuildInfo_String(t *testing.T) {
- t.Parallel()
-
- b := BuildInfo{
- Name: "oxynote_core",
- Version: semver.MustParse("1.2.3"),
- Commit: "abc1234",
- Timestamp: time.Date(2021, 1, 2, 3, 4, 0, 0, time.UTC),
- }
-
- assert.Equal(
- t,
- "oxynote_core 1.2.3 (abc1234; 2021-01-02 03:04 UTC)",
- b.String(),
- )
-}
-
func Test_BuildInfo_VersionName(t *testing.T) {
cc := map[string]struct {
Version semver.Version
diff --git a/server/core/internal/datasource/simulation/simulation.go b/server/core/internal/datasource/simulation/simulation.go
index 50fadf6a..c72fbd8c 100644
--- a/server/core/internal/datasource/simulation/simulation.go
+++ b/server/core/internal/datasource/simulation/simulation.go
@@ -305,12 +305,7 @@ func simulated(block document.Block) bool {
return false
}
- active, ok := block.Attrs.Get(document.AttrSimulationActive)
- if !ok {
- return false
- }
-
- return active.Bool()
+ return block.Attrs.Get(document.AttrSimulationActive).Bool()
}
// probeSpan reads how far back the block's window reaches.
diff --git a/server/core/internal/datasource/simulation/simulation_test.go b/server/core/internal/datasource/simulation/simulation_test.go
index 4eb280a3..972e8238 100644
--- a/server/core/internal/datasource/simulation/simulation_test.go
+++ b/server/core/internal/datasource/simulation/simulation_test.go
@@ -2,13 +2,12 @@ package simulation
import (
"context"
- "encoding/json"
"log/slog"
"testing"
"time"
- "github.com/oxynote/oxynote/server/core/internal/assistant/block"
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
+ "github.com/oxynote/oxynote/server/core/internal/assistant/markup"
datasourceCore "github.com/oxynote/oxynote/server/core/internal/datasource"
datasourceMock "github.com/oxynote/oxynote/server/core/internal/datasource/_mock"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
@@ -507,17 +506,11 @@ func Test_Checker_Check(t *testing.T) {
assert.True(t, call.System)
require.Len(t, call.Ops, 1)
- op, opErr := call.Ops[0]()
- require.NoError(t, opErr)
-
- raw, jerr := json.Marshal(op)
- require.NoError(t, jerr)
-
- assert.JSONEq(
- t,
- `{"kind":"update_attrs","block_uid":"block-1","attrs":{"simulationActive":false}}`,
- string(raw),
- )
+ assert.Equal(t, edit.Operation{
+ Kind: "update_attrs",
+ BlockUID: "block-1",
+ Attrs: map[string]any{"simulationActive": false},
+ }, call.Ops[0])
}
if c.ProbeSpan > 0 {
@@ -543,7 +536,7 @@ func Test_Checker_Check(t *testing.T) {
func Test_probeSpan(t *testing.T) {
t.Parallel()
- known := block.MetricEnums()[document.AttrTimeRange]
+ known := markup.MetricEnums()[document.AttrTimeRange]
assert.Len(t, _probeSpans, len(known))
diff --git a/server/core/internal/document/attribute.go b/server/core/internal/document/attribute.go
index 3fc1b01a..2b1713f6 100644
--- a/server/core/internal/document/attribute.go
+++ b/server/core/internal/document/attribute.go
@@ -1,37 +1,14 @@
package document
-import "maps"
-
// Attributes is the attribute map carried by document nodes and
// marks. It behaves as a plain map (range, index, len all work) and
// adds typed access to individual attributes via Get.
type Attributes map[string]any
-// Copy returns a shallow copy of the attributes. A nil receiver
-// yields an empty, non-nil map, so the result is always safe to
-// mutate.
-func (a Attributes) Copy() Attributes {
- out := make(Attributes, len(a)+1)
-
- maps.Copy(out, a)
-
- return out
-}
-
-// Has reports whether the named attribute is present.
-func (a Attributes) Has(name string) bool {
- _, ok := a[name]
-
- return ok
-}
-
-// Get returns the named attribute and whether it is present. The
-// zero Attribute is returned when the name is absent, so its typed
-// accessors yield zero values.
-func (a Attributes) Get(name string) (Attribute, bool) {
- v, ok := a[name]
-
- return Attribute{value: v}, ok
+// Get returns the named attribute. The zero Attribute is returned when
+// the name is absent, so its typed accessors yield zero values.
+func (a Attributes) Get(name string) Attribute {
+ return Attribute{value: a[name]}
}
// Value returns the named attribute's raw value and whether it is set
@@ -40,8 +17,7 @@ func (a Attributes) Get(name string) (Attribute, bool) {
// It differs from Get in what it calls set: the editor writes null for
// a field nobody has filled in, and a caller reading one back gets the
// null with it, so a key present with a null value is as unset here as
-// a key that is absent. Get answers the narrower question of whether
-// the key is there at all.
+// a key that is absent.
func (a Attributes) Value(name string) (any, bool) {
v, ok := a[name]
if !ok || v == nil {
diff --git a/server/core/internal/document/attribute_keys.go b/server/core/internal/document/attribute_keys.go
index e7751e19..95a54d58 100644
--- a/server/core/internal/document/attribute_keys.go
+++ b/server/core/internal/document/attribute_keys.go
@@ -50,6 +50,9 @@ const (
// AttrChecked indicates that a task item is done.
AttrChecked = "checked"
+ // AttrStart is the number an ordered list counts from.
+ AttrStart = "start"
+
// AttrDataSourceID is the data source a metric block queries.
AttrDataSourceID = "dataSourceId"
@@ -65,27 +68,9 @@ const (
// AttrRefreshInterval is how often a metric block re-queries.
AttrRefreshInterval = "refreshInterval"
- // AttrThresholds holds a metric block's threshold rows.
- AttrThresholds = "thresholds"
-
- // AttrBaseThresholdColor is a gauge's base colour.
- AttrBaseThresholdColor = "baseThresholdColor"
-
- // AttrDecimals is the number of decimals a metric block shows.
- AttrDecimals = "decimals"
-
// AttrUnitType is the unit a metric block's values carry.
AttrUnitType = "unitType"
- // AttrUnitCustom is the unit label when AttrUnitType is custom.
- AttrUnitCustom = "unitCustom"
-
- // AttrAxisBoundsMin is a metric block's fixed lower axis bound.
- AttrAxisBoundsMin = "axisBoundsMin"
-
- // AttrAxisBoundsMax is a metric block's fixed upper axis bound.
- AttrAxisBoundsMax = "axisBoundsMax"
-
// AttrSimulationPreset names the generated series a metric block
// draws while AttrSimulationActive is set. It is unset on a block
// nobody has simulated. Once set, it stays after the simulation ends.
diff --git a/server/core/internal/document/attribute_test.go b/server/core/internal/document/attribute_test.go
index bd1dc4ef..c163c4a7 100644
--- a/server/core/internal/document/attribute_test.go
+++ b/server/core/internal/document/attribute_test.go
@@ -4,7 +4,6 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
@@ -12,23 +11,6 @@ func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
-func Test_Attributes_Copy(t *testing.T) {
- // nil receiver
- out := Attributes(nil).Copy()
- require.NotNil(t, out)
- assert.Empty(t, out)
-
- // populated receiver
- src := Attributes{"level": 2, "icon": "lucide:text"}
- out = src.Copy()
- assert.Equal(t, src, out)
-
- // mutation independence
- out["level"] = 3
-
- assert.Equal(t, 2, src["level"])
-}
-
func Test_Attributes_Value(t *testing.T) {
t.Parallel()
@@ -49,25 +31,13 @@ func Test_Attributes_Value(t *testing.T) {
assert.Nil(t, v)
}
-func Test_Attributes_Has(t *testing.T) {
- a := Attributes{"src": "x", "empty": ""}
-
- assert.True(t, a.Has("src"))
- assert.True(t, a.Has("empty"))
- assert.False(t, a.Has("missing"))
- assert.False(t, Attributes(nil).Has("src"))
-}
-
func Test_Attributes_Get(t *testing.T) {
+ t.Parallel()
+
a := Attributes{"src": "x"}
- got, ok := a.Get("src")
- assert.True(t, ok)
- assert.Equal(t, "x", got.String())
-
- got, ok = a.Get("missing")
- assert.False(t, ok)
- assert.Empty(t, got.String())
+ assert.Equal(t, Attribute{value: "x"}, a.Get("src"))
+ assert.Equal(t, Attribute{}, a.Get("missing"))
}
func Test_Attribute_Int(t *testing.T) {
@@ -86,7 +56,7 @@ func Test_Attribute_Int(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- a, _ := Attributes{"k": c.Value}.Get("k")
+ a := Attributes{"k": c.Value}.Get("k")
assert.Equal(t, c.Result, a.Int())
})
}
@@ -106,7 +76,7 @@ func Test_Attribute_String(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- a, _ := Attributes{"k": c.Value}.Get("k")
+ a := Attributes{"k": c.Value}.Get("k")
assert.Equal(t, c.Result, a.String())
})
}
@@ -126,7 +96,7 @@ func Test_Attribute_Bool(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- a, _ := Attributes{"k": c.Value}.Get("k")
+ a := Attributes{"k": c.Value}.Get("k")
assert.Equal(t, c.Result, a.Bool())
})
}
diff --git a/server/core/internal/document/document.go b/server/core/internal/document/document.go
index 271f28bb..119393a2 100644
--- a/server/core/internal/document/document.go
+++ b/server/core/internal/document/document.go
@@ -190,26 +190,6 @@ func (d Document) ApplyProtection(protected bool, updatedBy string) Document {
return nd
}
-// Content is a lightweight view of one document at one branch:
-// just the identity, display name, and parsed content. Used by
-// downstream pipelines that need to walk the content tree without
-// pulling raw_content or other branch metadata.
-type Content struct {
- // OrganizationID is the identifier of the organization that
- // owns this document.
- OrganizationID string `db:"fk_organization_id"`
-
- // DocumentID is the identifier of the document.
- DocumentID xid.ID `db:"fk_document_id"`
-
- // DocumentName is the document's display name on the branch
- // this content was loaded from.
- DocumentName string `db:"document_name"`
-
- // Content is the parsed ProseMirror tree for the branch.
- Content RootBlock `db:"content"`
-}
-
// BranchSummary holds summary information for a document branch.
// It omits content and raw content and is used for listing branches.
type BranchSummary struct {
diff --git a/server/core/internal/document/history/history.go b/server/core/internal/document/history/history.go
index 613db05c..8eced6f3 100644
--- a/server/core/internal/document/history/history.go
+++ b/server/core/internal/document/history/history.go
@@ -121,7 +121,7 @@ func NewHooks(doc document.Document, hooks []hook.Hook) Hooks {
for _, hk := range hooks {
if hk.BlockID.Valid {
- if _, ok := doc.Content.FindByUID(hk.BlockID.String); !ok {
+ if !doc.Content.HasBlock(hk.BlockID.String) {
continue
}
}
diff --git a/server/core/internal/document/hook/hook.go b/server/core/internal/document/hook/hook.go
index 761f3a04..bd42e886 100644
--- a/server/core/internal/document/hook/hook.go
+++ b/server/core/internal/document/hook/hook.go
@@ -134,7 +134,8 @@ type Hook struct {
// CreatedAt is the time when the hook was created.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
- // UpdatedAt is the timestamp when the document was last updated.
+ // UpdatedAt is when someone last changed the hook's settings or reset
+ // it. Null until then.
UpdatedAt null.Time `json:"updatedAt" db:"updated_at"`
// SoftDeletedAt is the time when the hook was soft deleted (not found in the document).
diff --git a/server/core/internal/document/hook/manager/write.go b/server/core/internal/document/hook/manager/write.go
index fd723d39..9db159b6 100644
--- a/server/core/internal/document/hook/manager/write.go
+++ b/server/core/internal/document/hook/manager/write.go
@@ -11,6 +11,7 @@ import (
"github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/document/hook/processor"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
"github.com/rs/xid"
)
@@ -266,6 +267,10 @@ func (m *Manager) ResetHook(ctx context.Context, id, documentID xid.ID, organiza
return nil, m.hookErr(err)
}
+ // a reset is a change someone made, as a settings update is, unlike
+ // the resets a background check does.
+ hk.UpdatedAt = null.TimeFrom(timeutil.Now())
+
if err = m.db.UpdateDocumentHook(ctx, *hk); err != nil {
return nil, err
}
diff --git a/server/core/internal/document/hook/manager/write_test.go b/server/core/internal/document/hook/manager/write_test.go
index 6a1aeb11..23cf83f3 100644
--- a/server/core/internal/document/hook/manager/write_test.go
+++ b/server/core/internal/document/hook/manager/write_test.go
@@ -866,6 +866,7 @@ func Test_Manager_ResetHook(t *testing.T) {
assert.Equal(t, *hk, ff[0].Hk)
assert.Equal(t, c.Status, hk.Status)
+ assert.True(t, hk.UpdatedAt.Valid)
assert.Equal(t, c.Published, pub.codes)
assert.Equal(t, []hook.Hook{*hk}, changes.hooks)
})
diff --git a/server/core/internal/document/node.go b/server/core/internal/document/node.go
index 5bd1f4fa..6c9a5ead 100644
--- a/server/core/internal/document/node.go
+++ b/server/core/internal/document/node.go
@@ -6,7 +6,9 @@ import (
"encoding/json"
"errors"
"fmt"
+ "maps"
"net/url"
+ "reflect"
"strings"
"github.com/oxynote/oxynote/server/core/pkg/strutil"
@@ -71,32 +73,11 @@ func (rb RootBlock) FindByUID(uid string) (Block, bool) {
return Block{}, false
}
-// FindParentTypeByUID recursively searches the root's subtree for the
-// block with the given uid and returns the node type of the block that
-// directly holds it. A top-level match reports BlockNodeDoc.
-func (rb RootBlock) FindParentTypeByUID(uid string) (BlockNodeType, bool) {
- for _, b := range rb.Content {
- if id, ok := b.UID(); ok && id == uid {
- return BlockNodeDoc, true
- }
-
- if t, ok := b.FindParentTypeByUID(uid); ok {
- return t, true
- }
- }
-
- return "", false
-}
-
// HasBlock searches for a block with the given ID in the root block.
func (rb RootBlock) HasBlock(blockID string) bool {
- for _, b := range rb.Content {
- if b.HasBlock(blockID) {
- return true
- }
- }
+ _, ok := rb.FindByUID(blockID)
- return false
+ return ok
}
// Value transforms stopper type into a database entry.
@@ -156,55 +137,6 @@ func (b Block) UID() (string, bool) {
return id, ok
}
-// Flatten returns the concatenated text of the block's entire
-// subtree. Text within one block reads as written, and the text of
-// separate blocks is separated by a single space. Used to derive a flat
-// searchable representation from a structured ProseMirror tree.
-func (b Block) Flatten() string {
- f := flattener{}
- f.walk(b)
-
- return string(f.buf)
-}
-
-// flattener accumulates the text Flatten returns.
-type flattener struct {
- // buf holds the text written so far.
- buf []byte
-
- // apart indicates that a block boundary was crossed since the last
- // text, so the next text is set apart by a space.
- apart bool
-}
-
-// walk appends the text of b's subtree. Adjacent text nodes are one run
-// split only by marks, so they join as written; any other node, such as
-// a block or a hard break, sets the text around it apart.
-func (f *flattener) walk(b Block) {
- if b.Type == BlockNodeText {
- if b.Text == "" {
- return
- }
-
- if f.apart && len(f.buf) > 0 && f.buf[len(f.buf)-1] != ' ' {
- f.buf = append(f.buf, ' ')
- }
-
- f.apart = false
- f.buf = append(f.buf, b.Text...)
-
- return
- }
-
- f.apart = true
-
- for _, c := range b.Content {
- f.walk(c)
- }
-
- f.apart = true
-}
-
// FindByUID recursively searches the block's subtree (self included)
// for the block with the given uid and returns it.
func (b Block) FindByUID(uid string) (Block, bool) {
@@ -221,29 +153,29 @@ func (b Block) FindByUID(uid string) (Block, bool) {
return Block{}, false
}
-// FindParentTypeByUID recursively searches the block's children for the
-// block with the given uid and returns the node type of the block that
-// directly holds it — b's own type for a direct child. A match on b
-// itself is not reported, since b's parent is not in view.
-func (b Block) FindParentTypeByUID(uid string) (BlockNodeType, bool) {
- for _, cb := range b.Content {
- if id, ok := cb.UID(); ok && id == uid {
- return b.Type, true
- }
-
- if t, ok := cb.FindParentTypeByUID(uid); ok {
- return t, true
+// FirstChild returns the block's first child of type typ, or the zero
+// block when it has none.
+func (b Block) FirstChild(typ BlockNodeType) Block {
+ for _, c := range b.Content {
+ if c.Type == typ {
+ return c
}
}
- return "", false
+ return Block{}
}
-// HasBlock recursively searches for a block with the given ID.
-func (b Block) HasBlock(blockID string) bool {
- _, ok := b.FindByUID(blockID)
+// PlainText returns the text of b's text children, marks dropped.
+func (b Block) PlainText() string {
+ var sb strings.Builder
- return ok
+ for _, c := range b.Content {
+ if c.Type == BlockNodeText {
+ sb.WriteString(c.Text)
+ }
+ }
+
+ return sb.String()
}
// Mark represents a mark, such as bold or italic.
@@ -255,6 +187,12 @@ type Mark struct {
Attrs Attributes `json:"attrs,omitempty"`
}
+// Equal reports whether m and o are the same mark with the same
+// attributes.
+func (m Mark) Equal(o Mark) bool {
+ return m.Type == o.Type && maps.EqualFunc(m.Attrs, o.Attrs, reflect.DeepEqual)
+}
+
// UnmarshalJSON decodes the mark, reading attrs that are not an object
// as none. Older documents hold marks with `true` for attrs, and one
// failing mark would keep the whole document from being stored.
diff --git a/server/core/internal/document/node_test.go b/server/core/internal/document/node_test.go
index 2bd57a1f..824b6d42 100644
--- a/server/core/internal/document/node_test.go
+++ b/server/core/internal/document/node_test.go
@@ -87,52 +87,6 @@ func Test_Block_FindByUID(t *testing.T) {
}
}
-func Test_RootBlock_FindParentTypeByUID(t *testing.T) {
- cc := map[string]struct {
- UID string
- Parent BlockNodeType
- Found bool
- }{
- "Top-level block": {UID: "p1", Parent: BlockNodeDoc, Found: true},
- "Nested block": {UID: "li1", Parent: BlockNodeBulletList, Found: true},
- "Deeply nested block": {UID: "lp1", Parent: BlockNodeListItem, Found: true},
- "Missing uid": {UID: "nope"},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- parent, ok := stubTree().FindParentTypeByUID(c.UID)
- assert.Equal(t, c.Found, ok)
- assert.Equal(t, c.Parent, parent)
- })
- }
-}
-
-func Test_Block_FindParentTypeByUID(t *testing.T) {
- cc := map[string]struct {
- UID string
- Parent BlockNodeType
- Found bool
- }{
- "Direct child": {UID: "li1", Parent: BlockNodeBulletList, Found: true},
- "Grandchild": {UID: "lp1", Parent: BlockNodeListItem, Found: true},
- "Self match is not a match": {UID: "l1"},
- "Missing uid": {UID: "nope"},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- parent, ok := stubTree().Content[1].FindParentTypeByUID(c.UID)
- assert.Equal(t, c.Found, ok)
- assert.Equal(t, c.Parent, parent)
- })
- }
-}
-
func Test_RootBlock_HasBlock(t *testing.T) {
t.Parallel()
@@ -140,11 +94,40 @@ func Test_RootBlock_HasBlock(t *testing.T) {
assert.False(t, stubTree().HasBlock("nope"))
}
-func Test_Block_HasBlock(t *testing.T) {
+func Test_Block_FirstChild(t *testing.T) {
t.Parallel()
- assert.True(t, stubTree().Content[1].HasBlock("li1"))
- assert.False(t, stubTree().Content[1].HasBlock("nope"))
+ item := stubTree().Content[1].Content[0]
+
+ assert.Equal(t, item.Content[0], item.FirstChild(BlockNodeParagraph))
+ assert.Equal(t, Block{}, item.FirstChild(BlockNodeHeading))
+}
+
+func Test_Block_PlainText(t *testing.T) {
+ t.Parallel()
+
+ b := Block{
+ Type: BlockNodeParagraph,
+ Content: []Block{
+ {Type: BlockNodeText, Text: "a "},
+ {Type: BlockNodeImageBlock},
+ {Type: BlockNodeText, Text: "b", Marks: []Mark{{Type: "bold"}}},
+ },
+ }
+
+ assert.Equal(t, "a b", b.PlainText())
+ assert.Empty(t, Block{}.PlainText())
+}
+
+func Test_Mark_Equal(t *testing.T) {
+ t.Parallel()
+
+ link := Mark{Type: "link", Attrs: Attributes{"href": "https://x.test", "title": nil}}
+
+ assert.True(t, link.Equal(Mark{Type: "link", Attrs: Attributes{"href": "https://x.test", "title": nil}}))
+ assert.False(t, link.Equal(Mark{Type: "link", Attrs: Attributes{"href": "https://y.test", "title": nil}}))
+ assert.False(t, link.Equal(Mark{Type: "bold"}))
+ assert.True(t, Mark{Type: "bold"}.Equal(Mark{Type: "bold", Attrs: Attributes{}}))
}
// stubMarkedTree builds a tree with comment marks, nodeCommentId
@@ -185,101 +168,6 @@ func Test_Block_UID(t *testing.T) {
assert.False(t, ok)
}
-func Test_Block_Flatten(t *testing.T) {
- t.Parallel()
-
- cc := map[string]struct {
- Block Block
- Expected string
- }{
- "Text leaf returns its text": {
- Block: Block{Type: BlockNodeText, Text: "hello"},
- Expected: "hello",
- },
- "Adjacent fragments read as written": {
- Block: Block{
- Type: BlockNodeParagraph,
- Content: []Block{
- {Type: BlockNodeText, Text: "my"},
- {Type: BlockNodeText, Text: "_var"},
- {Type: BlockNodeText, Text: " is "},
- {Type: BlockNodeText, Text: "set"},
- },
- },
- Expected: "my_var is set",
- },
- "Inline atom separates the fragments around it": {
- Block: Block{
- Type: BlockNodeParagraph,
- Content: []Block{
- {Type: BlockNodeText, Text: "line"},
- {Type: "hardBreak"},
- {Type: BlockNodeText, Text: "break"},
- },
- },
- Expected: "line break",
- },
- "Sibling blocks are space separated": {
- Block: Block{
- Type: BlockNodeBlockquote,
- Content: []Block{
- {
- Type: BlockNodeParagraph,
- Content: []Block{
- {Type: BlockNodeText, Text: "first "},
- },
- },
- {
- Type: BlockNodeParagraph,
- Content: []Block{
- {Type: BlockNodeText, Text: "second"},
- },
- },
- },
- },
- Expected: "first second",
- },
- "Nested subtrees are concatenated": {
- Block: Block{
- Type: BlockNodeBulletList,
- Content: []Block{
- {
- Type: BlockNodeListItem,
- Content: []Block{
- {Type: BlockNodeText, Text: "one"},
- },
- },
- {
- Type: BlockNodeListItem,
- Content: []Block{
- {Type: BlockNodeText, Text: "two"},
- },
- },
- },
- },
- Expected: "one two",
- },
- "Empty text fragments are skipped": {
- Block: Block{
- Type: BlockNodeParagraph,
- Content: []Block{
- {Type: BlockNodeText, Text: ""},
- {Type: BlockNodeText, Text: "solo"},
- },
- },
- Expected: "solo",
- },
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Expected, c.Block.Flatten())
- })
- }
-}
-
func Test_Mark_UnmarshalJSON(t *testing.T) {
t.Parallel()
diff --git a/server/core/internal/server/internal/mcp/bridge.go b/server/core/internal/server/internal/mcp/bridge.go
index c51969dc..6d4a4f35 100644
--- a/server/core/internal/server/internal/mcp/bridge.go
+++ b/server/core/internal/server/internal/mcp/bridge.go
@@ -13,13 +13,16 @@ import (
// every tool says whether it reaches outside the organization's own
// documents.
func annotations(e tools.Entry) *mcp.ToolAnnotations {
+ tr := e.Info.Traits
+
+ // a data-source tool reaches whatever its connection points at.
out := &mcp.ToolAnnotations{
- ReadOnlyHint: !e.Write,
- OpenWorldHint: new(e.OpenWorld()),
+ ReadOnlyHint: !tr.Write,
+ OpenWorldHint: new(tr.DataSource),
}
- if e.Write {
- out.DestructiveHint = new(e.DestroysContent())
+ if tr.Write {
+ out.DestructiveHint = new(tr.Destructive || tr.Overwrites)
}
return out
@@ -35,7 +38,7 @@ func (h *Handler) toolHandler(e tools.Entry) mcp.ToolHandler {
args = json.RawMessage("{}")
}
- res, err := e.Tool.Run(ctx, args)
+ out, err := e.Tool.Run(ctx, args)
if err != nil {
//nolint:nilerr // execution failures are isError results, not protocol errors
return &mcp.CallToolResult{
@@ -44,18 +47,6 @@ func (h *Handler) toolHandler(e tools.Entry) mcp.ToolHandler {
}, nil
}
- content := []mcp.Content{&mcp.TextContent{Text: res.Output}}
-
- // a call links back to every branch it changed, so the client
- // can follow the edit straight to its target.
- for _, t := range res.Documents {
- content = append(content, &mcp.ResourceLink{
- URI: resourceURI(t.DocumentID, t.BranchID),
- Name: t.DocumentID.String(),
- MIMEType: _documentMIMEType,
- })
- }
-
- return &mcp.CallToolResult{Content: content}, nil
+ return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: out}}}, nil
}
}
diff --git a/server/core/internal/server/internal/mcp/bridge_test.go b/server/core/internal/server/internal/mcp/bridge_test.go
index 97933379..c6ec1146 100644
--- a/server/core/internal/server/internal/mcp/bridge_test.go
+++ b/server/core/internal/server/internal/mcp/bridge_test.go
@@ -7,7 +7,6 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/oxynote/oxynote/server/core/internal/assistant/tools"
- "github.com/rs/xid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,16 +17,13 @@ type stubRunner struct {
// out is what Run reports as its output.
out string
- // docs is what Run reports as the branches it changed.
- docs []tools.Touched
-
// runErr fails Run when set.
runErr error
}
// Run returns the configured outcome.
-func (s *stubRunner) Run(context.Context, json.RawMessage) (tools.Result, error) {
- return tools.Result{Output: s.out, Documents: s.docs}, s.runErr
+func (s *stubRunner) Run(context.Context, json.RawMessage) (string, error) {
+ return s.out, s.runErr
}
func Test_annotations(t *testing.T) {
@@ -44,23 +40,23 @@ func Test_annotations(t *testing.T) {
ReadOnly: true,
},
"Data-source tool reaches an open world": {
- Entry: tools.Entry{Traits: tools.Traits{DataSource: true}},
+ Entry: tools.Entry{Info: tools.Info{Traits: tools.Traits{DataSource: true}}},
ReadOnly: true,
OpenWorld: true,
},
"Additive write tool": {
- Entry: tools.Entry{Traits: tools.Traits{Write: true}},
+ Entry: tools.Entry{Info: tools.Info{Traits: tools.Traits{Write: true}}},
Destructive: new(bool),
},
"Destructive write tool": {
- Entry: tools.Entry{Traits: tools.Traits{Write: true, Destructive: true}},
+ Entry: tools.Entry{Info: tools.Info{Traits: tools.Traits{Write: true, Destructive: true}}},
Destructive: func() *bool {
v := true
return &v
}(),
},
"Overwriting write tool is destructive to a client": {
- Entry: tools.Entry{Traits: tools.Traits{Write: true, Overwrites: true}},
+ Entry: tools.Entry{Info: tools.Info{Traits: tools.Traits{Write: true, Overwrites: true}}},
Destructive: func() *bool {
v := true
return &v
@@ -85,9 +81,6 @@ func Test_annotations(t *testing.T) {
func Test_Handler_toolHandler(t *testing.T) {
t.Parallel()
- doc1, doc2 := xid.New(), xid.New()
- branch1, branch2 := xid.New(), xid.New()
-
hdl := &Handler{log: discardLog()}
req := func(args string) *mcp.CallToolRequest {
@@ -100,11 +93,10 @@ func Test_Handler_toolHandler(t *testing.T) {
}
cc := map[string]struct {
- Entry tools.Entry
- Args string
- IsError bool
- Text string
- LinkURIs []string
+ Entry tools.Entry
+ Args string
+ IsError bool
+ Text string
}{
"Tool failure becomes an isError result": {
Entry: tools.Entry{Tool: &stubRunner{runErr: assert.AnError}},
@@ -112,25 +104,8 @@ func Test_Handler_toolHandler(t *testing.T) {
IsError: true,
Text: assert.AnError.Error(),
},
- "A call that changed nothing carries no link": {
- Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`}},
- Args: `{"document_id":"doc1"}`,
- Text: `{"ok":true}`,
- },
- "A call links the document it changed": {
- Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`, docs: []tools.Touched{{DocumentID: doc1, BranchID: branch1}}}, Traits: tools.Traits{Write: true}},
- Args: `{"document_id":"` + doc1.String() + `"}`,
- Text: `{"ok":true}`,
- LinkURIs: []string{resourceURI(doc1, branch1)},
- },
- "A call links every document it changed": {
- Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`, docs: []tools.Touched{{DocumentID: doc1, BranchID: branch1}, {DocumentID: doc2, BranchID: branch2}}}, Traits: tools.Traits{Write: true}},
- Args: `{}`,
- Text: `{"ok":true}`,
- LinkURIs: []string{resourceURI(doc1, branch1), resourceURI(doc2, branch2)},
- },
- "A write that changed nothing has no link to offer": {
- Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`}, Traits: tools.Traits{Write: true}},
+ "A call's output is its text": {
+ Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`}, Info: tools.Info{Traits: tools.Traits{Write: true}}},
Args: `{}`,
Text: `{"ok":true}`,
},
@@ -152,13 +127,7 @@ func Test_Handler_toolHandler(t *testing.T) {
require.True(t, ok)
assert.Equal(t, c.Text, text.Text)
- require.Len(t, res.Content, 1+len(c.LinkURIs))
-
- for n, want := range c.LinkURIs {
- link, lok := res.Content[1+n].(*mcp.ResourceLink)
- require.True(t, lok)
- assert.Equal(t, want, link.URI)
- }
+ assert.Len(t, res.Content, 1)
})
}
}
diff --git a/server/core/internal/server/internal/mcp/http_test.go b/server/core/internal/server/internal/mcp/http_test.go
index e6b0b1d2..11172df7 100644
--- a/server/core/internal/server/internal/mcp/http_test.go
+++ b/server/core/internal/server/internal/mcp/http_test.go
@@ -270,7 +270,7 @@ func Test_Handler_ServeHTTP(t *testing.T) {
instructions, ok := result["instructions"].(string)
require.True(t, ok, "instructions: %v", result["instructions"])
assert.Contains(t, instructions, "Oxynote")
- assert.Contains(t, instructions, "## Canonical block model")
+ assert.Contains(t, instructions, "## Content")
})
}
diff --git a/server/core/internal/server/internal/mcp/resources.go b/server/core/internal/server/internal/mcp/resources.go
index f98d507d..bc72b23f 100644
--- a/server/core/internal/server/internal/mcp/resources.go
+++ b/server/core/internal/server/internal/mcp/resources.go
@@ -30,7 +30,7 @@ func (h *Handler) addResources(
srv.AddResourceTemplate(&mcp.ResourceTemplate{
Name: "document-branch",
Title: "Oxynote document branch",
- Description: "One branch of an Oxynote document, by document and branch id: its metadata, branches and blocks in canonical JSON form.",
+ Description: "One branch of an Oxynote document, by document and branch id: its metadata, its branches and its content as XML markup, one element per block.",
MIMEType: _documentMIMEType,
URITemplate: _resourceURITemplate,
}, read)
@@ -84,7 +84,7 @@ func (h *Handler) readDocument(set *tools.Set) mcp.ResourceHandler {
return nil, errors.New("get_document tool not registered")
}
- res, err := get.Tool.Run(ctx, json.RawMessage(raw))
+ out, err := get.Tool.Run(ctx, json.RawMessage(raw))
if err != nil {
if errutil.IsNotFound(err) || errors.Is(err, tools.ErrUnknownDocument) || errors.Is(err, tools.ErrUnknownBranch) {
return nil, mcp.ResourceNotFoundError(req.Params.URI)
@@ -99,7 +99,7 @@ func (h *Handler) readDocument(set *tools.Set) mcp.ResourceHandler {
Contents: []*mcp.ResourceContents{{
URI: req.Params.URI,
MIMEType: _documentMIMEType,
- Text: res.Output,
+ Text: out,
}},
}, nil
}
diff --git a/server/core/internal/server/internal/mcp/resources_test.go b/server/core/internal/server/internal/mcp/resources_test.go
index 78e08395..8da77bc7 100644
--- a/server/core/internal/server/internal/mcp/resources_test.go
+++ b/server/core/internal/server/internal/mcp/resources_test.go
@@ -219,7 +219,7 @@ func Test_Handler_readDocument(t *testing.T) {
FetchDocumentByBranchIDFunc: stubDraftDocument(docID),
FetchDocumentBranchesFunc: stubBranches,
},
- Text: `"branch":{"id":"` + _stubDraftBranchID.String() + `","name":"draft","protected":false,"default":false}`,
+ Text: `{"id":"` + _stubDraftBranchID.String() + `","name":"draft","protected":false,"default":false`,
},
}
diff --git a/server/core/internal/server/internal/mcp/server.go b/server/core/internal/server/internal/mcp/server.go
index effe7a56..ae8a2eb6 100644
--- a/server/core/internal/server/internal/mcp/server.go
+++ b/server/core/internal/server/internal/mcp/server.go
@@ -10,14 +10,6 @@ import (
"github.com/oxynote/oxynote/server/core/internal/buildinfo"
)
-// _scopes is the OAuth scope each kind of tool access answers to. An
-// access with no scope here is offered to no client.
-var _scopes = map[tools.Access]string{
- tools.AccessRead: ScopeDocumentRead,
- tools.AccessWrite: ScopeDocumentWrite,
- tools.AccessDataSource: ScopeDataSourceRead,
-}
-
// server builds the MCP server for one request's identity: the tools
// the token's scopes allow, plus the organization's documents as
// resources. Stateless transport means every request gets a fresh
@@ -57,7 +49,7 @@ func (h *Handler) server(r *http.Request) *mcp.Server {
// a tool no scope grants is not offered: an internal one addresses
// conversation state this surface has none of, and the rest answer
// to the scope their access maps onto.
- scope, ok := _scopes[e.Access()]
+ scope, ok := toolScope(e.Info.Traits)
if !ok || !slices.Contains(session.Scopes, scope) {
continue
}
@@ -65,7 +57,7 @@ func (h *Handler) server(r *http.Request) *mcp.Server {
// the wire shape is the tool's own description: same name, same
// prose, same argument schema the assistant's model sees.
srv.AddTool(&mcp.Tool{
- Name: string(e.Name),
+ Name: string(e.Info.Name),
Description: e.Info.Description,
InputSchema: e.Info.Schema(),
Annotations: annotations(e),
@@ -78,3 +70,19 @@ func (h *Handler) server(r *http.Request) *mcp.Server {
return srv
}
+
+// toolScope returns the OAuth scope a token needs to be offered a tool
+// with the given traits. An internal tool answers to no scope: it
+// addresses conversation state only the assistant has.
+func toolScope(tr tools.Traits) (string, bool) {
+ switch {
+ case tr.Internal:
+ return "", false
+ case tr.DataSource:
+ return ScopeDataSourceRead, true
+ case tr.Write:
+ return ScopeDocumentWrite, true
+ default:
+ return ScopeDocumentRead, true
+ }
+}
diff --git a/server/core/internal/server/internal/mcp/server_test.go b/server/core/internal/server/internal/mcp/server_test.go
index 08bd0a22..8f852e2c 100644
--- a/server/core/internal/server/internal/mcp/server_test.go
+++ b/server/core/internal/server/internal/mcp/server_test.go
@@ -3,6 +3,7 @@ package mcp
import (
"testing"
+ "github.com/oxynote/oxynote/server/core/internal/assistant/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +13,6 @@ import (
var _readToolNames = []string{
"list_documents",
"get_document",
- "read_block",
"list_tags",
"list_hooks",
"search_documents",
@@ -24,21 +24,16 @@ var _writeToolNames = []string{
"create_document",
"delete_document",
"update_document",
- "insert_block",
- "replace_block",
- "update_block_text",
- "update_block_attrs",
+ "insert_blocks",
+ "replace_blocks",
"delete_block",
"move_block",
"create_tag",
"update_tag",
"delete_tag",
- "assign_tag",
- "unassign_tag",
- "move_tag",
+ "set_tag_assignment",
"create_hook",
"update_hook",
- "reset_hook",
"delete_hook",
}
@@ -46,14 +41,10 @@ var _writeToolNames = []string{
// to a token holding data-sources:read.
var _dataSourceToolNames = []string{
"list_data_sources",
- "get_prometheus_metadata",
- "list_prometheus_label_names",
- "list_prometheus_label_values",
+ "get_data_source_metadata",
+ "list_prometheus_labels",
"list_prometheus_series",
- "query_prometheus",
- "get_sql_metadata",
- "get_sql_query_labels",
- "query_sql",
+ "query_data_source",
}
// checkDataSourceToolShape asserts a data-source tool reaches the wire
@@ -61,7 +52,7 @@ var _dataSourceToolNames = []string{
func checkDataSourceToolShape(t *testing.T, defs map[string]map[string]any) {
t.Helper()
- query := defs["query_prometheus"]
+ query := defs["query_data_source"]
require.NotNil(t, query)
assert.NotEmpty(t, query["description"])
@@ -170,3 +161,30 @@ func Test_Handler_server(t *testing.T) {
})
}
}
+
+func Test_toolScope(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Traits tools.Traits
+ Scope string
+ Found bool
+ }{
+ "Read": {Scope: ScopeDocumentRead, Found: true},
+ "Write": {Traits: tools.Traits{Write: true}, Scope: ScopeDocumentWrite, Found: true},
+ "Data source": {Traits: tools.Traits{DataSource: true}, Scope: ScopeDataSourceRead, Found: true},
+ "Data source over write": {Traits: tools.Traits{Write: true, DataSource: true}, Scope: ScopeDataSourceRead, Found: true},
+ "Internal": {Traits: tools.Traits{Internal: true}},
+ "Internal write": {Traits: tools.Traits{Write: true, Internal: true}},
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ scope, found := toolScope(c.Traits)
+ assert.Equal(t, c.Scope, scope)
+ assert.Equal(t, c.Found, found)
+ })
+ }
+}
diff --git a/server/core/pkg/strutil/nanoid.go b/server/core/pkg/strutil/nanoid.go
index b7170560..331247e9 100644
--- a/server/core/pkg/strutil/nanoid.go
+++ b/server/core/pkg/strutil/nanoid.go
@@ -1,3 +1,5 @@
+// Package strutil provides small string helpers shared across the
+// codebase.
package strutil
import (
diff --git a/server/core/pkg/strutil/nanoid_test.go b/server/core/pkg/strutil/nanoid_test.go
index 2c5d351e..7881ff6a 100644
--- a/server/core/pkg/strutil/nanoid_test.go
+++ b/server/core/pkg/strutil/nanoid_test.go
@@ -5,8 +5,13 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "go.uber.org/goleak"
)
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
func Test_NanoID(t *testing.T) {
t.Parallel()
diff --git a/server/core/pkg/strutil/strutil.go b/server/core/pkg/strutil/strutil.go
deleted file mode 100644
index c4897a75..00000000
--- a/server/core/pkg/strutil/strutil.go
+++ /dev/null
@@ -1,30 +0,0 @@
-// Package strutil provides small string helpers shared across the
-// codebase.
-package strutil
-
-import "strings"
-
-// Ellipsize returns at most maxLen runes of s, appending a single
-// ellipsis rune when the input was longer. A non-positive maxLen
-// returns s unchanged.
-func Ellipsize(s string, maxLen int) string {
- if maxLen <= 0 {
- return s
- }
-
- runes := []rune(s)
- if len(runes) <= maxLen {
- return s
- }
-
- return string(runes[:maxLen]) + "…"
-}
-
-// Preview returns at most maxLen runes of s, collapsed to a single line
-// and trimmed, elided the way Ellipsize does.
-func Preview(s string, maxLen int) string {
- s = strings.ReplaceAll(s, "\n", " ")
- s = strings.TrimSpace(s)
-
- return Ellipsize(s, maxLen)
-}
diff --git a/server/core/pkg/strutil/strutil_test.go b/server/core/pkg/strutil/strutil_test.go
deleted file mode 100644
index 9aec7605..00000000
--- a/server/core/pkg/strutil/strutil_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package strutil
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "go.uber.org/goleak"
-)
-
-func TestMain(m *testing.M) {
- goleak.VerifyTestMain(m)
-}
-
-func Test_Ellipsize(t *testing.T) {
- cc := map[string]struct {
- Input string
- MaxLen int
- Result string
- }{
- "Shorter than limit": {Input: "abc", MaxLen: 5, Result: "abc"},
- "Exactly at limit": {Input: "abcde", MaxLen: 5, Result: "abcde"},
- "Longer than limit": {Input: "abcdef", MaxLen: 5, Result: "abcde…"},
- "Zero limit passthrough": {Input: "abcdef", MaxLen: 0, Result: "abcdef"},
- "Negative limit": {Input: "abcdef", MaxLen: -1, Result: "abcdef"},
- "Multibyte runes": {Input: "ąčęėįš", MaxLen: 3, Result: "ąčę…"},
- "Empty input": {Input: "", MaxLen: 3, Result: ""},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, Ellipsize(c.Input, c.MaxLen))
- })
- }
-}
-
-func Test_Preview(t *testing.T) {
- cc := map[string]struct {
- Input string
- MaxLen int
- Result string
- }{
- "Newlines collapse": {Input: "a\nb\nc", MaxLen: 10, Result: "a b c"},
- "Whitespace trimmed": {Input: " x ", MaxLen: 10, Result: "x"},
- "Long text elided": {Input: "abcdef", MaxLen: 3, Result: "abc…"},
- "Empty input": {Input: "", MaxLen: 3, Result: ""},
- }
-
- for cn, c := range cc {
- t.Run(cn, func(t *testing.T) {
- t.Parallel()
-
- assert.Equal(t, c.Result, Preview(c.Input, c.MaxLen))
- })
- }
-}
diff --git a/web/app/composables/useAIChat.nuxt.test.ts b/web/app/composables/useAIChat.nuxt.test.ts
index ef54940b..bc331cf5 100644
--- a/web/app/composables/useAIChat.nuxt.test.ts
+++ b/web/app/composables/useAIChat.nuxt.test.ts
@@ -312,8 +312,8 @@ describe("useAIChat", { concurrent: false }, () => {
type: ServerMessageType.ConfirmRequest,
turnId: "t1",
actions: [
- { tool: "insert_block", summary: "Insert a callout" },
- { tool: "update_block_text", summary: "Reword the intro" },
+ { tool: "insert_blocks", summary: "Insert a callout" },
+ { tool: "replace_blocks", summary: "Reword the intro" },
],
})