tidy some functions in assistant tools package

This commit is contained in:
david 2026-09-08 20:48:33 +03:00
parent 366701c267
commit bf035ad0ee
22 changed files with 304 additions and 337 deletions

View File

@ -111,6 +111,55 @@ const (
BlockParamList Type = "split_doc_param_list"
)
// Label turns the type into a friendlier label for the confirm UI.
// Unknown types fall through verbatim.
func (t Type) Label() string {
switch t {
case BlockParagraph:
return "a paragraph"
case BlockHeading:
return "a heading"
case BlockBlockquote:
return "a blockquote"
case BlockBulletList:
return "a bullet list"
case BlockOrderedList:
return "an ordered list"
case BlockTaskList:
return "a task list"
case BlockCallout:
return "a callout"
case BlockCode:
return "a code block"
case BlockTitledCode:
return "a titled code block"
case BlockMermaid:
return "a mermaid diagram"
case BlockHorizontalRule:
return "a divider"
case BlockImage:
return "an image"
case BlockFile:
return "a file attachment"
case BlockFigma:
return "a figma embed"
case BlockMetric:
return "a metric"
case BlockMetricGrid:
return "a metric grid"
case BlockSplitDoc:
return "a split documentation block"
case BlockParamList:
return "a parameter list"
}
if t == "" {
return "a block"
}
return "a " + string(t) + " block"
}
// Block is the canonical representation of a single block as the AI
// reads and writes it. Not every field is meaningful for every block
// type:

View File

@ -92,3 +92,39 @@ func Test_Block_CollectAttributeValues(t *testing.T) {
})
}
}
func Test_Type_Label(t *testing.T) {
cc := map[string]struct {
Kind Type
Result string
}{
"Paragraph": {Kind: "paragraph", Result: "a paragraph"},
"Heading": {Kind: "heading", Result: "a heading"},
"Blockquote": {Kind: "blockquote", Result: "a blockquote"},
"Bullet list": {Kind: "bullet_list", Result: "a bullet list"},
"Ordered list": {Kind: "ordered_list", Result: "an ordered list"},
"Task list": {Kind: "task_list", Result: "a task list"},
"Callout": {Kind: "callout", Result: "a callout"},
"Code": {Kind: "code", Result: "a code block"},
"Titled code": {Kind: "titled_code", Result: "a titled code block"},
"Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"},
"Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"},
"Image": {Kind: "image", Result: "an image"},
"File": {Kind: "file", Result: "a file attachment"},
"Figma": {Kind: "figma", Result: "a figma embed"},
"Metric": {Kind: "metric", Result: "a metric"},
"Metric grid": {Kind: "metric_grid", Result: "a metric grid"},
"Split doc": {Kind: "split_doc", Result: "a split documentation block"},
"Param list": {Kind: "split_doc_param_list", Result: "a parameter list"},
"Empty": {Kind: "", Result: "a block"},
"Unknown": {Kind: "wibble", Result: "a wibble block"},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, c.Kind.Label())
})
}
}

View File

@ -30,16 +30,6 @@ type Result struct {
Errors []OpError `json:"errors"`
}
// OpError describes one operation's failure on the Node side.
type OpError struct {
// Index is the position of the failing op in the request's
// operations array.
Index int `json:"index"`
// Message is the short reason from Node.
Message string `json:"message"`
}
// Client posts batched operations to the Node hocuspocus service's
// internal operations endpoint. The base URL points at the Node
// service (e.g. http://auth-realtime:8081); endpoint replaces its

View File

@ -0,0 +1,48 @@
package edit
import (
"fmt"
"strings"
)
// OpError describes one operation's failure on the Node side.
type OpError struct {
// Index is the position of the failing op in the request's
// operations array.
Index int `json:"index"`
// Message is the short reason from Node.
Message string `json:"message"`
}
// describe rewrites the Node-side message into what the model can act
// on: a uid it holds no block for points at get_document, a reference
// inside the moved block says what to pick instead, and an operation
// kind is named as the tool the model knows it by. A message with no
// rewrite passes through as it is.
func (e OpError) describe() string {
for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} {
if uid, ok := strings.CutPrefix(e.Message, prefix); ok {
return fmt.Sprintf("no block with uid %s in this document; call get_document for the current uids", uid)
}
}
if uid, ok := strings.CutPrefix(e.Message, "reference_uid is inside the moved block: "); ok {
return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid)
}
return strings.ReplaceAll(e.Message, "update_text", "update_block_text")
}
// JoinOpErrors renders per-operation failures as one message. The
// index is left out: a tool sends a single operation, so naming its
// position says nothing the reader can act on.
func JoinOpErrors(errs []OpError) string {
msgs := make([]string, 0, len(errs))
for _, e := range errs {
msgs = append(msgs, e.describe())
}
return strings.Join(msgs, "; ")
}

View File

@ -0,0 +1,58 @@
package edit
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_OpError_describe(t *testing.T) {
t.Parallel()
cc := map[string]struct {
Msg string
Result string
}{
"Reference uid not found": {
Msg: "reference_uid not found: r1",
Result: "no block with uid r1 in this document; call get_document for the current uids",
},
"Block uid not found": {
Msg: "block_uid not found: b1",
Result: "no block with uid b1 in this document; call get_document for the current uids",
},
"Reference inside the moved block": {
Msg: "reference_uid is inside the moved block: r1",
Result: "reference block r1 sits inside the block being moved; choose a reference outside it",
},
"Operation kind named as its tool": {
Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.",
Result: "update_block_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_block_text on the block holding the text.",
},
"Unknown message passes through": {
Msg: "cannot move a block relative to itself: a",
Result: "cannot move a block relative to itself: a",
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, OpError{Message: c.Msg}.describe())
})
}
}
func Test_JoinOpErrors(t *testing.T) {
t.Parallel()
got := JoinOpErrors([]OpError{
{Index: 0, Message: "block_uid not found: a"},
{Index: 1, Message: "something else"},
})
// 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)
}

View File

@ -11,6 +11,7 @@ import (
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
"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"
)
@ -259,7 +260,7 @@ func (insertBlock) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameInsertBlock, err)
}
return "Updating " + docLabel(doc), nil
return "Updating " + doc.Title(), nil
}
// Summary describes the insertion the model wants to make.
@ -275,17 +276,17 @@ func (insertBlock) Summary(inp DescribeInput) (ActionSummary, error) {
return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameInsertBlock, err)
}
kind := blockKindLabel(in.Block.Type)
kind := in.Block.Type.Label()
var summary string
switch in.Position {
case positionStart:
summary = fmt.Sprintf("Prepend %s to %s", kind, docLabel(doc))
summary = fmt.Sprintf("Prepend %s to %s", kind, doc.Title())
case positionEnd:
summary = fmt.Sprintf("Append %s to %s", kind, docLabel(doc))
summary = fmt.Sprintf("Append %s to %s", kind, doc.Title())
default:
summary = fmt.Sprintf("Insert %s %s a block in %s", kind, in.Position, docLabel(doc))
summary = fmt.Sprintf("Insert %s %s a block in %s", kind, in.Position, doc.Title())
}
return ActionSummary{
@ -423,7 +424,7 @@ func (replaceBlock) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameReplaceBlock, err)
}
return "Updating " + docLabel(doc), nil
return "Updating " + doc.Title(), nil
}
// Summary describes the replacement the model wants to make.
@ -443,7 +444,7 @@ func (replaceBlock) Summary(inp DescribeInput) (ActionSummary, error) {
Tool: NameReplaceBlock,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
Summary: fmt.Sprintf("Replace a block in %s with %s", doc.DocumentName, blockKindLabel(in.Block.Type)),
Summary: fmt.Sprintf("Replace a block in %s with %s", doc.DocumentName, in.Block.Type.Label()),
}, nil
}
@ -547,7 +548,7 @@ func (updateBlockText) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameUpdateBlockText, err)
}
return "Updating " + docLabel(doc), nil
return "Updating " + doc.Title(), nil
}
// Summary previews the text the model wants to write.
@ -558,7 +559,7 @@ func (updateBlockText) Summary(inp DescribeInput) (ActionSummary, error) {
return ActionSummary{}, err
}
preview := textPreview(in.Text, _maxPreviewLen)
preview := strutil.Preview(in.Text, _maxPreviewLen)
doc, err := inp.FetchBranch(in.DocumentID, in.BranchID)
if err != nil {
@ -567,7 +568,7 @@ func (updateBlockText) Summary(inp DescribeInput) (ActionSummary, error) {
summary := fmt.Sprintf("Update a block in %s: %q", doc.DocumentName, preview)
if preview == "" {
summary = "Update text of a block in " + docLabel(doc)
summary = "Update text of a block in " + doc.Title()
}
return ActionSummary{
@ -678,7 +679,7 @@ func (updateBlockAttrs) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameUpdateBlockAttrs, err)
}
return "Updating " + docLabel(doc), nil
return "Updating " + doc.Title(), nil
}
// Summary names the attributes the model wants to set.
@ -702,7 +703,7 @@ func (updateBlockAttrs) Summary(inp DescribeInput) (ActionSummary, error) {
Tool: NameUpdateBlockAttrs,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
Summary: fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), docLabel(doc)),
Summary: fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), doc.Title()),
}, nil
}
@ -810,7 +811,7 @@ func (deleteBlock) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameDeleteBlock, err)
}
return "Updating " + docLabel(doc), nil
return "Updating " + doc.Title(), nil
}
// Summary describes the deletion the model wants to make.
@ -830,7 +831,7 @@ func (deleteBlock) Summary(inp DescribeInput) (ActionSummary, error) {
Tool: NameDeleteBlock,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
Summary: "Delete a block in " + docLabel(doc),
Summary: "Delete a block in " + doc.Title(),
}, nil
}
@ -945,7 +946,7 @@ func (moveBlock) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameMoveBlock, err)
}
return "Updating " + docLabel(doc), nil
return "Updating " + doc.Title(), nil
}
// Summary describes the move the model wants to make.
@ -965,7 +966,7 @@ func (moveBlock) Summary(inp DescribeInput) (ActionSummary, error) {
Tool: NameMoveBlock,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
Summary: fmt.Sprintf("Move a block %s another block in %s", in.Position, docLabel(doc)),
Summary: fmt.Sprintf("Move a block %s another block in %s", in.Position, doc.Title()),
}, nil
}

View File

@ -3,7 +3,6 @@ package tools
import (
"context"
"encoding/json"
"sync"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
@ -17,23 +16,14 @@ import (
// survives the checkpoint written when the turn is interrupted.
const SessionKeyAutoApprove = "oxynote_assistant_auto_approve"
// _registerOnce guards the one-time registration of the types that
// cross an interrupt. Registering twice panics.
var _registerOnce sync.Once
// registerConfirmTypes teaches the checkpoint serialiser about the
// values a paused write carries. A turn parked on a confirmation is
// written to Redis, and an unregistered type fails that write, which
// would surface as a failed turn rather than a prompt.
//
// The names are the identities those checkpoints are written under.
// Renaming one strands every turn already parked in Redis, so a rename
// is only free while nothing is deployed.
func registerConfirmTypes() {
_registerOnce.Do(func() {
schema.RegisterName[confirmState]("oxynote_assistant_confirm_state")
schema.RegisterName[ActionSummary]("oxynote_assistant_action_summary")
})
// the checkpoint is gob-encoded, and both values sit behind interface
// fields in it: confirmState as the interrupt state, ActionSummary as
// the interrupt info. gob needs a name for a concrete type it meets
// through an interface, and a checkpoint already in Redis carries the
// name it was written with, so a rename fails to decode it.
func init() {
schema.RegisterName[confirmState]("oxynote_assistant_confirm_state")
schema.RegisterName[ActionSummary]("oxynote_assistant_action_summary")
}
// ActionSummary is the human-readable description of a pending write op

View File

@ -9,6 +9,7 @@ import (
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/tag"
"github.com/rs/xid"
)
@ -117,7 +118,7 @@ type getDocument struct {
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}], 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, 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, tagName, color}], 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.",
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."},
@ -139,7 +140,7 @@ func (getDocument) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameGetDocument, err)
}
return "Reading " + docLabel(doc), nil
return "Reading " + doc.Title(), nil
}
// Execute fetches the branch and summarises its content. The document
@ -175,10 +176,14 @@ func (getDocument) Execute(inp Input) (string, error) {
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: tagInfos(tags),
Tags: make([]tag.Summary, 0, len(tags)),
Blocks: walkDocForAssistant(doc.Content.Content),
}
for _, tg := range tags {
out.Tags = append(out.Tags, tg.Summary())
}
for _, b := range branches {
out.Branches = append(out.Branches, branchInfo{
ID: b.BranchID,
@ -219,7 +224,7 @@ type documentResult struct {
Branches []branchInfo `json:"branches"`
// Tags is every tag the branch read carries.
Tags []tagInfo `json:"tags"`
Tags []tag.Summary `json:"tags"`
// Blocks is the branch's content, one row per block.
Blocks []docSummaryEntry `json:"blocks"`

View File

@ -308,7 +308,7 @@ func Test_getDocument_Execute(t *testing.T) {
DB: tagged,
Args: `{` + targetArgs(_stubBranchID) + `}`,
Contains: []string{
`"tags":[{"id":"` + _testTagID.String() + `","name":"Production","color":"#22c55e"}]`,
`"tags":[{"id":"` + _testTagID.String() + `","tagName":"Production","color":"#22c55e","hidden":false}]`,
},
},
"Root document omits the parent": {

View File

@ -371,7 +371,7 @@ func (createHook) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameCreateHook, err)
}
return "Adding a hook to " + docLabel(doc), nil
return "Adding a hook to " + doc.Title(), nil
}
// Summary names the hook type and where it goes.
@ -777,7 +777,7 @@ func describeHook(inp DescribeInput, name Name, ref hookRefArgs) (*document.Docu
// hookLabel names a hook by its type, the document it is on and, when
// anchored, its block, in the words the confirm card uses.
func hookLabel(tp hook.Type, blockUID null.String, doc *document.Document) string {
label := tp.HumanizedString() + " hook on " + docLabel(doc)
label := tp.HumanizedString() + " hook on " + doc.Title()
if blockUID.Valid {
label += ", block " + blockUID.String

View File

@ -675,7 +675,7 @@ func (i *input) ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) err
// 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", joinOpErrors(res.Errors))
return fmt.Errorf("applying edit: %s", edit.JoinOpErrors(res.Errors))
}
if len(res.Errors) > 0 {

View File

@ -489,8 +489,12 @@ func (assignTag) 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}.",
Properties: branchTagProps(),
Required: []string{"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"},
}
}
@ -512,7 +516,7 @@ func (assignTag) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameAssignTag, err)
}
return "Tagging " + docLabel(doc), nil
return "Tagging " + doc.Title(), nil
}
// Summary names the tag and the document it goes on.
@ -537,7 +541,7 @@ func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) {
Tool: NameAssignTag,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, docLabel(doc)),
Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title()),
}, nil
}
@ -567,8 +571,12 @@ 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: branchTagProps(),
Required: []string{"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"},
}
}
@ -590,7 +598,7 @@ func (unassignTag) Title(inp DescribeInput) (string, error) {
return "", fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err)
}
return "Untagging " + docLabel(doc), nil
return "Untagging " + doc.Title(), nil
}
// Summary names the tag and the document it comes off.
@ -615,7 +623,7 @@ func (unassignTag) Summary(inp DescribeInput) (ActionSummary, error) {
Tool: NameUnassignTag,
DocumentID: doc.ID,
DocumentName: doc.DocumentName,
Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, docLabel(doc)),
Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title()),
}, nil
}
@ -753,36 +761,3 @@ type movedTagResult struct {
// SortIndex is the 0-based position it now has.
SortIndex int `json:"sort_index"`
}
// tagInfo describes one tag a branch carries to the model.
type tagInfo struct {
// ID is the tag id, which is what a tool's tag_id takes.
ID xid.ID `json:"id"`
// Name is the tag's display name.
Name string `json:"name"`
// Color is the tag's colour as a hex triplet.
Color string `json:"color"`
}
// tagInfos converts the tags a branch carries into the model's shape.
func tagInfos(tags []tag.Tag) []tagInfo {
out := make([]tagInfo, 0, len(tags))
for _, t := range tags {
out = append(out, tagInfo{ID: t.ID, Name: t.TagName, Color: t.Color})
}
return out
}
// branchTagProps builds the argument schema assign_tag and unassign_tag
// share.
func branchTagProps() map[string]any {
return 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."},
}
}

View File

@ -137,15 +137,6 @@ func Test_listTags_Execute(t *testing.T) {
}
}
func Test_tagInfos(t *testing.T) {
t.Parallel()
assert.Equal(t, []tagInfo{}, tagInfos(nil))
assert.Equal(t, []tagInfo{
{ID: _testTagID, Name: _stubTagName, Color: _stubTagColor},
}, tagInfos([]tag.Tag{{ID: _testTagID, TagName: _stubTagName, Color: _stubTagColor, OrganizationID: "org"}}))
}
func Test_createTagArgs_Validate(t *testing.T) {
t.Parallel()
@ -597,16 +588,6 @@ func Test_branchTagArgs_Validate(t *testing.T) {
})
}
func Test_branchTagProps(t *testing.T) {
t.Parallel()
props := branchTagProps()
assert.Contains(t, props, "document_id")
assert.Contains(t, props, "branch_id")
assert.Contains(t, props, "tag_id")
}
func Test_assignTag_Info(t *testing.T) {
t.Parallel()

File diff suppressed because one or more lines are too long

View File

@ -100,8 +100,6 @@ type Set struct {
// New creates a fresh instance of Set.
func New(deps *Deps) *Set {
registerConfirmTypes()
all := []Tool{
listDocuments{},
getDocument{},

View File

@ -3,12 +3,6 @@ package tools
import (
"encoding/json"
"fmt"
"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/document"
"github.com/oxynote/oxynote/server/core/pkg/strutil"
)
// result is the single place tool result envelopes are serialised;
@ -22,113 +16,7 @@ func result(v any) (string, error) {
return string(data), nil
}
// joinOpErrors renders per-operation failures as one message. The
// index is left out: a tool sends a single operation, so naming its
// position says nothing the reader can act on.
func joinOpErrors(errs []edit.OpError) string {
msgs := make([]string, 0, len(errs))
for _, e := range errs {
msgs = append(msgs, describeOpError(e.Message))
}
return strings.Join(msgs, "; ")
}
// _opKindNames maps the realtime service's operation kinds, which it
// names in its errors, to the tools the model knows them as.
var _opKindNames = strings.NewReplacer("update_text", "update_block_text")
// describeOpError rewrites a realtime-service operation error into
// what the model can act on: a uid it holds no block for points at
// get_document, a reference inside the moved block says what to pick
// instead, and an operation kind is named as its tool. A message with
// no rewrite passes through as it is.
func describeOpError(msg string) string {
for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} {
if uid, ok := strings.CutPrefix(msg, prefix); ok {
return fmt.Sprintf("no block with uid %s in this document; call get_document for the current uids", uid)
}
}
if uid, ok := strings.CutPrefix(msg, "reference_uid is inside the moved block: "); ok {
return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid)
}
return _opKindNames.Replace(msg)
}
// errRequired reports an argument the tool cannot act without.
func errRequired(key string) error {
return fmt.Errorf("%s is required", key)
}
// blockKindLabel turns a canonical block type into a friendlier label
// for the confirm UI. Unknown types fall through verbatim.
func blockKindLabel(kind block.Type) string {
switch kind {
case block.BlockParagraph:
return "a paragraph"
case block.BlockHeading:
return "a heading"
case block.BlockBlockquote:
return "a blockquote"
case block.BlockBulletList:
return "a bullet list"
case block.BlockOrderedList:
return "an ordered list"
case block.BlockTaskList:
return "a task list"
case block.BlockCallout:
return "a callout"
case block.BlockCode:
return "a code block"
case block.BlockTitledCode:
return "a titled code block"
case block.BlockMermaid:
return "a mermaid diagram"
case block.BlockHorizontalRule:
return "a divider"
case block.BlockImage:
return "an image"
case block.BlockFile:
return "a file attachment"
case block.BlockFigma:
return "a figma embed"
case block.BlockMetric:
return "a metric"
case block.BlockMetricGrid:
return "a metric grid"
case block.BlockSplitDoc:
return "a split documentation block"
case block.BlockParamList:
return "a parameter list"
}
if kind == "" {
return "a block"
}
return "a " + string(kind) + " block"
}
// textPreview returns at most maxLen runes of s, collapsed to a single
// line. Used for surfacing short snippets of an upcoming text edit in
// the confirm UI.
func textPreview(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.TrimSpace(s)
return strutil.Ellipsize(s, maxLen)
}
// docLabel names a document the way a status line or a confirm card
// shows it: by display name, with the branch appended when it is not the
// document's default one, so a write aimed at a branch reads as such.
func docLabel(doc *document.Document) string {
if doc.Default {
return doc.DocumentName
}
return doc.DocumentName + " on branch " + doc.BranchName
}

View File

@ -3,8 +3,6 @@ package tools
import (
"testing"
"github.com/oxynote/oxynote/server/core/internal/assistant/block"
"github.com/oxynote/oxynote/server/core/internal/assistant/edit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -27,111 +25,3 @@ func Test_errRequired(t *testing.T) {
assert.EqualError(t, errRequired("document_id"), "document_id is required")
}
func Test_blockKindLabel(t *testing.T) {
cc := map[string]struct {
Kind block.Type
Result string
}{
"Paragraph": {Kind: "paragraph", Result: "a paragraph"},
"Heading": {Kind: "heading", Result: "a heading"},
"Blockquote": {Kind: "blockquote", Result: "a blockquote"},
"Bullet list": {Kind: "bullet_list", Result: "a bullet list"},
"Ordered list": {Kind: "ordered_list", Result: "an ordered list"},
"Task list": {Kind: "task_list", Result: "a task list"},
"Callout": {Kind: "callout", Result: "a callout"},
"Code": {Kind: "code", Result: "a code block"},
"Titled code": {Kind: "titled_code", Result: "a titled code block"},
"Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"},
"Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"},
"Image": {Kind: "image", Result: "an image"},
"File": {Kind: "file", Result: "a file attachment"},
"Figma": {Kind: "figma", Result: "a figma embed"},
"Metric": {Kind: "metric", Result: "a metric"},
"Metric grid": {Kind: "metric_grid", Result: "a metric grid"},
"Split doc": {Kind: "split_doc", Result: "a split documentation block"},
"Param list": {Kind: "split_doc_param_list", Result: "a parameter list"},
"Empty": {Kind: "", Result: "a block"},
"Unknown": {Kind: "wibble", Result: "a wibble block"},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, blockKindLabel(c.Kind))
})
}
}
func Test_textPreview(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, textPreview(c.Input, c.MaxLen))
})
}
}
func Test_joinOpErrors(t *testing.T) {
t.Parallel()
got := joinOpErrors([]edit.OpError{
{Index: 0, Message: "block_uid not found: a"},
{Index: 1, Message: "something else"},
})
// 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)
}
func Test_describeOpError(t *testing.T) {
t.Parallel()
cc := map[string]struct {
Msg string
Result string
}{
"Reference uid not found": {
Msg: "reference_uid not found: r1",
Result: "no block with uid r1 in this document; call get_document for the current uids",
},
"Block uid not found": {
Msg: "block_uid not found: b1",
Result: "no block with uid b1 in this document; call get_document for the current uids",
},
"Reference inside the moved block": {
Msg: "reference_uid is inside the moved block: r1",
Result: "reference block r1 sits inside the block being moved; choose a reference outside it",
},
"Operation kind named as its tool": {
Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.",
Result: "update_block_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_block_text on the block holding the text.",
},
"Unknown message passes through": {
Msg: "cannot move a block relative to itself: a",
Result: "cannot move a block relative to itself: a",
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, describeOpError(c.Msg))
})
}
}

View File

@ -266,6 +266,17 @@ func (d Document) ApplyBranchUpdate(name null.String, protected null.Bool, updat
return nd
}
// Title names the document the way a status line or a confirm card
// shows it: by display name, with the branch appended when it is not the
// default one, so a write aimed at a branch reads as such.
func (d Document) Title() string {
if d.Default {
return d.DocumentName
}
return d.DocumentName + " on branch " + d.BranchName
}
// HistoryEntry returns a history entry for the current state of the document.
// Entries aggregate per branch and per time bucket: the branch is part of the
// ID so that two branches of one document edited within the same bucket keep

View File

@ -109,6 +109,12 @@ func NewTag(inp CreateInput, organizationID, userID string) Tag {
}
}
// Summary returns the tag on its own: the identity, name and colour,
// without the per-user hidden flag or the documents carrying it.
func (t Tag) Summary() Summary {
return Summary{ID: t.ID, TagName: t.TagName, Color: t.Color}
}
// UpdateInput is the input for renaming or recolouring a tag. A field left
// unset keeps its current value.
type UpdateInput struct {

View File

@ -108,6 +108,15 @@ func Test_NewTag(t *testing.T) {
assert.Zero(t, tg.SortIndex)
}
func Test_Tag_Summary(t *testing.T) {
t.Parallel()
id := xid.New()
tg := Tag{ID: id, OrganizationID: "org1", TagName: "Production", Color: "#22c55e", SortIndex: 3}
assert.Equal(t, Summary{ID: id, TagName: "Production", Color: "#22c55e"}, tg.Summary())
}
func Test_Summaries_Swap(t *testing.T) {
t.Parallel()

View File

@ -2,6 +2,8 @@
// 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.
@ -17,3 +19,12 @@ func Ellipsize(s string, maxLen int) string {
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)
}

View File

@ -34,3 +34,24 @@ func Test_Ellipsize(t *testing.T) {
})
}
}
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))
})
}
}