Files
HarborForge.PlexumPlugin/internal/kbblock/kbblock_test.go
hzhang 9e43021ba5 feat(kb): dynamic-kb-* tool family + <kb-block> subblock provider
Implements the HF side of Plexum DESIGN-DYNAMIC-BLOCK.md Phase 2 — the
HarborForge knowledge-base block agents cache facts into per session.
Cross-runtime aligned with the ClawSkills workflow text (same tool
names + input schemas + return shapes will land on openclaw side later).

manifest.json:
  - 5 new dynamic-kb-* tool contracts (list-kbs / list-topics /
    list-facts / cache / evict)
  - dynamicSubblocks contract entry declaring this plugin owns
    the "kb-block" <dynamic-block> subblock

internal/kbblock/ (new):
  - Per-session storage at <PLEXUM_PROFILE_ROOT>/agents/<id>/sessions/
    <sid>/plugins/harbor-forge/kb-block.json
  - Entry carries ID (HF backend DB primary key) + KBCode + SourceTopic
    + Content + InsertSeq for §9 #4 cache-insertion-order rendering
  - Render emits <kb-fact id=N kb=<code> source=topic:<slug>>...
    </kb-fact> (no title/description per §9 #8; source attr omitted
    when empty)
  - Fade NOT applied in v1 — §9 #3 lock has fade params shared with
    memory but implementation deferred until prod data informs whether
    KB needs it; agent dynamic-kb-evict is the only eviction path
  - 11 unit tests

internal/kbclient/ (new):
  - Typed HTTP client for HarborForge.Backend KB routes verified
    against app/api/routers/knowledge.py
  - GET /knowledge-bases[?project=<code>] (list KBs)
  - GET /knowledge-bases/{kb_code}/topics (list topics)
  - GET /knowledge-bases/{kb_code}/tree (full hierarchy — ListFacts
    flattens this client-side filtered by topic ids; backend has no
    flat list-facts-in-topic route)
  - GET /knowledge-facts/{id} per fact (GetFacts batch loop)
  - Auth: plugin-level Bearer APIKey. Per-agent hf-token resolution
    is a TODO when SDK exposes secret-mgr access.

internal/tools/kb.go (new) + tools.go:
  - 5 tool functions hooked into Dispatch
  - KBDeps struct bundles Client + ProfileRoot + SessionFor + Turn
  - Cache/evict use SessionFor lookup populated by main.go's
    RenderDynamicSubblock (called per turn by host; carries sessionID)

cmd/plexum-harborforge-plugin/main.go:
  - kbClient field initialized when BackendURL + APIKey present
  - profileRoot cached for kbblock path resolution
  - agentSession sync.Map tracks agentID → sessionID; populated by
    RenderDynamicSubblock so subsequent tool calls in the same turn
    can resolve the per-session kb-block.json path
  - Implements sdkplugin.DynamicBlockProvider.RenderDynamicSubblock:
    opens kbblock for (agentID, sessionID) and returns its Render()
    body; host wraps in <kb-block>...</kb-block>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 20:15:34 +01:00

140 lines
3.5 KiB
Go

package kbblock
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSessionDir(t *testing.T) {
got := SessionDir("/root/.plexum", "alice", "s_1")
want := "/root/.plexum/agents/alice/sessions/s_1/plugins/harbor-forge"
if got != want {
t.Errorf("SessionDir = %q want %q", got, want)
}
}
func TestOpenMissingFile(t *testing.T) {
b, err := Open(t.TempDir(), "alice", "s_1")
if err != nil {
t.Fatal(err)
}
if b.Len() != 0 || b.NextSeq != 1 {
t.Errorf("empty open: Len=%d NextSeq=%d", b.Len(), b.NextSeq)
}
}
func TestOpenRequiresAllArgs(t *testing.T) {
if _, err := Open("", "a", "s"); err == nil {
t.Error("want error on empty profileRoot")
}
if _, err := Open("/x", "", "s"); err == nil {
t.Error("want error on empty agentID")
}
if _, err := Open("/x", "a", ""); err == nil {
t.Error("want error on empty sessionID")
}
}
func TestAddAndDuplicate(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
e := b.Add(42, "KB-PAYROT", "debugging", "OOM fix", 3)
if e == nil || e.ID != 42 || e.KBCode != "KB-PAYROT" || e.SourceTopic != "debugging" ||
e.InsertSeq != 1 || e.AddedAtTurn != 3 {
t.Errorf("entry wrong: %+v", e)
}
if b.Add(42, "X", "y", "z", 7) != nil {
t.Error("dup should return nil")
}
if b.Len() != 1 {
t.Errorf("Len=%d", b.Len())
}
}
func TestRoundtrip(t *testing.T) {
dir := t.TempDir()
b, _ := Open(dir, "a", "s")
b.Add(101, "KB-A", "topic-a", "a", 1)
b.Add(202, "KB-A", "topic-b", "b", 2)
if err := b.Save(); err != nil {
t.Fatal(err)
}
b2, _ := Open(dir, "a", "s")
if b2.Len() != 2 || b2.NextSeq != 3 {
t.Errorf("reopen Len=%d NextSeq=%d", b2.Len(), b2.NextSeq)
}
if !b2.Has(101) || !b2.Has(202) {
t.Error("entries missing after reopen")
}
}
func TestRenderInsertOrder(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(999, "K", "t", "first", 0)
b.Add(1, "K", "t", "second", 0)
out := b.Render()
idx999 := strings.Index(out, "id=999")
idx1 := strings.Index(out, "id=1 ")
if !(idx999 >= 0 && idx1 > idx999) {
t.Errorf("order broken: %q", out)
}
}
func TestRenderAttributes(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(42, "KB-PAYROT", "debugging", "OOM fix: bump heap", 0)
out := b.Render()
if !strings.HasPrefix(out, "<kb-fact id=42 kb=KB-PAYROT source=topic:debugging>\n") {
t.Errorf("head wrong: %q", out)
}
if !strings.HasSuffix(out, "</kb-fact>\n") {
t.Errorf("tail wrong: %q", out)
}
}
func TestRenderOmitsSourceWhenEmpty(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(7, "KB-X", "", "raw", 0)
out := b.Render()
if strings.Contains(out, "source=topic:") {
t.Errorf("source attr leaked: %q", out)
}
if !strings.Contains(out, "<kb-fact id=7 kb=KB-X>") {
t.Errorf("tight tag missing: %q", out)
}
}
func TestRemove(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
b.Add(1, "K", "t", "a", 0)
b.Add(2, "K", "t", "b", 0)
b.Add(3, "K", "t", "c", 0)
removed := b.Remove(2, 99, 3)
if len(removed) != 2 {
t.Errorf("removed=%v", removed)
}
if b.Len() != 1 || !b.Has(1) {
t.Errorf("post-state wrong: Len=%d", b.Len())
}
}
func TestRenderEmpty(t *testing.T) {
b, _ := Open(t.TempDir(), "a", "s")
if b.Render() != "" {
t.Error("empty Render should be \"\"")
}
}
func TestSaveEmptyNoFile(t *testing.T) {
dir := t.TempDir()
b, _ := Open(dir, "a", "s")
if err := b.Save(); err != nil {
t.Fatal(err)
}
path := filepath.Join(SessionDir(dir, "a", "s"), FileName)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file should not exist: %v", err)
}
}