Files
2026-05-30 14:16:04 -05:00

557 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, jest } from "bun:test"
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"
import type { BmClient } from "../bm-client.ts"
import { registerContextTool } from "./build-context.ts"
describe("context tool", () => {
let mockApi: OpenClawPluginApi
let mockClient: BmClient
beforeEach(() => {
mockApi = {
registerTool: jest.fn(),
} as any
mockClient = {
buildContext: jest.fn(),
} as any
})
describe("registerContextTool", () => {
it("should register build_context tool with correct configuration", () => {
registerContextTool(mockApi, mockClient)
expect(mockApi.registerTool).toHaveBeenCalledWith(
expect.objectContaining({
name: "build_context",
label: "Build Context",
description: expect.stringContaining(
"Navigate the Basic Memory knowledge graph via memory:// URLs",
),
parameters: expect.objectContaining({
type: "object",
properties: expect.objectContaining({
url: expect.objectContaining({
type: "string",
description: expect.stringContaining(
"memory://agents/decisions",
),
}),
depth: expect.objectContaining({
type: "number",
description: "How many relation hops to follow (default: 1)",
}),
project: expect.objectContaining({ type: "string" }),
}),
}),
execute: expect.any(Function),
}),
{ name: "build_context" },
)
})
})
describe("tool execution", () => {
let executeFunction: Function
beforeEach(() => {
registerContextTool(mockApi, mockClient)
const registerCall = (mockApi.registerTool as jest.MockedFunction<any>)
.mock.calls[0]
executeFunction = registerCall[0].execute
})
it("should build context with provided URL and depth", async () => {
const mockContext = {
results: [
{
primary_result: {
title: "Main Note",
permalink: "main-note",
content: "This is the main note content",
file_path: "notes/main-note.md",
},
observations: [
{ category: "decision", content: "Important decision was made" },
{ category: "insight", content: "Key insight discovered" },
],
related_results: [
{
type: "entity",
title: "Related Note 1",
permalink: "related-note-1",
relation_type: "references",
},
{
type: "entity",
title: "Related Note 2",
permalink: "related-note-2",
relation_type: "follows_from",
},
],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://projects/my-project",
depth: 2,
})
expect(mockClient.buildContext).toHaveBeenCalledWith(
"memory://projects/my-project",
2,
undefined,
)
expect(result).toEqual({
content: [
{
type: "text",
text: expect.stringContaining("## Main Note"),
},
],
details: {
url: "memory://projects/my-project",
depth: 2,
resultCount: 1,
},
})
// Check that the text contains expected sections
const text = result.content[0].text
expect(text).toContain("## Main Note")
expect(text).toContain("This is the main note content")
expect(text).toContain("### Observations")
expect(text).toContain("[decision] Important decision was made")
expect(text).toContain("[insight] Key insight discovered")
expect(text).toContain("### Related")
expect(text).toContain("references → **Related Note 1**")
expect(text).toContain("follows_from → **Related Note 2**")
})
it("should use default depth when not provided", async () => {
const mockContext = { results: [] }
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
await executeFunction("tool-call-id", {
url: "memory://test/url",
})
expect(mockClient.buildContext).toHaveBeenCalledWith(
"memory://test/url",
1,
undefined,
)
})
it("should handle context with no results", async () => {
const mockContext = { results: [] }
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://nonexistent/path",
depth: 1,
})
expect(result).toEqual({
content: [
{
type: "text",
text: 'No context found for "memory://nonexistent/path".',
},
],
details: {
url: "memory://nonexistent/path",
depth: 1,
resultCount: 0,
},
})
})
it("should handle context with undefined results", async () => {
const mockContext = {}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://test/url",
})
expect(result.content[0].text).toContain("No context found")
})
it("should handle multiple results", async () => {
const mockContext = {
results: [
{
primary_result: {
title: "First Note",
permalink: "first-note",
content: "First note content",
file_path: "notes/first-note.md",
},
observations: [],
related_results: [],
},
{
primary_result: {
title: "Second Note",
permalink: "second-note",
content: "Second note content",
file_path: "notes/second-note.md",
},
observations: [{ category: "note", content: "Single observation" }],
related_results: [
{
type: "entity",
title: "Related to Second",
permalink: "related-to-second",
relation_type: "depends_on",
},
],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://multi/result",
})
const text = result.content[0].text
expect(text).toContain("## First Note")
expect(text).toContain("First note content")
expect(text).toContain("## Second Note")
expect(text).toContain("Second note content")
expect(text).toContain("### Observations")
expect(text).toContain("[note] Single observation")
expect(text).toContain("### Related")
expect(text).toContain("depends_on → **Related to Second**")
expect(result.details.resultCount).toBe(2)
})
it("should handle results with no observations", async () => {
const mockContext = {
results: [
{
primary_result: {
title: "No Observations Note",
permalink: "no-observations",
content: "Content without observations",
file_path: "notes/no-observations.md",
},
observations: [],
related_results: [],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://test/url",
})
const text = result.content[0].text
expect(text).toContain("## No Observations Note")
expect(text).toContain("Content without observations")
expect(text).not.toContain("### Observations")
})
it("should handle results with no related results", async () => {
const mockContext = {
results: [
{
primary_result: {
title: "No Relations Note",
permalink: "no-relations",
content: "Content without relations",
file_path: "notes/no-relations.md",
},
observations: [{ category: "test", content: "Test observation" }],
related_results: [],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://test/url",
})
const text = result.content[0].text
expect(text).toContain("## No Relations Note")
expect(text).toContain("### Observations")
expect(text).toContain("[test] Test observation")
expect(text).not.toContain("### Related")
})
it("should format permalinks correctly in related results", async () => {
const mockContext = {
results: [
{
primary_result: {
title: "Main Note",
permalink: "main-note",
content: "Content",
file_path: "notes/main-note.md",
},
observations: [],
related_results: [
{
type: "entity",
title: "Related Note with Long Title",
permalink: "related-note-with-long-title",
relation_type: "references",
},
{
type: "entity",
title: "Short Note",
permalink: "short",
relation_type: "follows_from",
},
],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://test/url",
})
const text = result.content[0].text
expect(text).toContain(
"references → **Related Note with Long Title** (related-note-with-long-title)",
)
expect(text).toContain("follows_from → **Short Note** (short)")
})
it("should handle different memory URL formats", async () => {
const mockContext = { results: [] }
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const urls = [
"memory://projects/my-project",
"projects/simple-path",
"memory://agents/decisions/important",
"single-note",
]
for (const url of urls) {
await executeFunction("tool-call-id", { url })
expect(mockClient.buildContext).toHaveBeenCalledWith(url, 1, undefined)
}
expect(mockClient.buildContext).toHaveBeenCalledTimes(urls.length)
})
it("should handle various depth values", async () => {
const mockContext = { results: [] }
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const depths = [1, 2, 3, 5, 10]
for (const depth of depths) {
await executeFunction("tool-call-id", {
url: "memory://test",
depth,
})
expect(mockClient.buildContext).toHaveBeenCalledWith(
"memory://test",
depth,
undefined,
)
}
expect(mockClient.buildContext).toHaveBeenCalledTimes(depths.length)
})
it("should pass project to client.buildContext", async () => {
const mockContext = { results: [] }
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
await executeFunction("tool-call-id", {
url: "memory://test",
project: "other-project",
})
expect(mockClient.buildContext).toHaveBeenCalledWith(
"memory://test",
1,
"other-project",
)
})
it("should handle buildContext errors gracefully", async () => {
const contextError = new Error("Failed to build context")
;(mockClient.buildContext as jest.MockedFunction<any>).mockRejectedValue(
contextError,
)
const result = await executeFunction("tool-call-id", {
url: "memory://error/test",
depth: 1,
})
expect(result).toEqual({
content: [
{
type: "text",
text: 'Failed to build context for "memory://error/test". Check logs for details.',
},
],
details: {
url: "memory://error/test",
depth: 1,
error: "build_context_failed",
},
})
})
it("should handle network errors", async () => {
const networkError = new Error("Connection refused")
networkError.code = "ECONNREFUSED"
;(mockClient.buildContext as jest.MockedFunction<any>).mockRejectedValue(
networkError,
)
const result = await executeFunction("tool-call-id", {
url: "memory://network/error",
})
expect(result.content[0].text).toContain("Failed to build context")
})
it("should format complex observation categories", async () => {
const mockContext = {
results: [
{
primary_result: {
title: "Complex Observations",
permalink: "complex-obs",
content: "Content",
file_path: "notes/complex-obs.md",
},
observations: [
{
category: "decision-point",
content: "Critical decision made here",
},
{
category: "user_preference",
content: "User prefers dark mode",
},
{
category: "technical-detail",
content: "Uses TypeScript for safety",
},
],
related_results: [],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://complex",
})
const text = result.content[0].text
expect(text).toContain("[decision-point] Critical decision made here")
expect(text).toContain("[user_preference] User prefers dark mode")
expect(text).toContain("[technical-detail] Uses TypeScript for safety")
})
it("should preserve content formatting in primary results", async () => {
const formattedContent = `# Main Title
This is **bold** text with *italic* parts.
## Subsection
- List item 1
- List item 2
\`\`\`javascript
const code = "example";
\`\`\``
const mockContext = {
results: [
{
primary_result: {
title: "Formatted Note",
permalink: "formatted-note",
content: formattedContent,
file_path: "notes/formatted-note.md",
},
observations: [],
related_results: [],
},
],
}
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://formatted",
})
const text = result.content[0].text
expect(text).toContain(formattedContent)
})
it("should include URL and depth in response details", async () => {
const mockContext = { results: [] }
;(mockClient.buildContext as jest.MockedFunction<any>).mockResolvedValue(
mockContext,
)
const result = await executeFunction("tool-call-id", {
url: "memory://details/test",
depth: 3,
})
expect(result.details).toEqual({
url: "memory://details/test",
depth: 3,
resultCount: 0,
})
})
})
})