hyperguild#81. brain_pending lists brain/raw/ notes awaiting review; brain_promote moves one into the wiki. There was no way to say "reviewed, not worth promoting" — the only options were promote it or let it sit forever. Live consequence, confirmed this session: the pending queue has grown to 11 items, the oldest 152 days old, several of which are generic web scrapes nobody is ever going to promote but nobody could clear either except an un-audited rm bypassing the tool surface entirely. RejectNote (internal/api/promote.go) mirrors PromoteNote's exact safety shape: same basename-only filename validation, same write-then-delete ordering (the source survives any write failure), same collision check (never silently overwrite an existing rejected note under the same name). Stamps rejected_at (+ reject_reason if given) into frontmatter, moves brain/raw/<filename> -> brain/raw/rejected/<filename>. No index rebuild or auto-tunnel -- a rejected note never entered the wiki, so there's nothing to index. raw/rejected/ is a subdirectory ListPending's non-recursive os.ReadDir never walks into, so a rejected note is never re-surfaced as pending -- the human decision, once made, stays made. brain_reject wired into the MCP tool list and dispatch, mirroring brain_promote's schema shape (filename required, reason optional). Human-invoked only, same as promote -- never called automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
434 lines
16 KiB
Go
434 lines
16 KiB
Go
package mcp_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func toolCall(t *testing.T, srv http.Handler, name string, args map[string]any) map[string]any {
|
|
t.Helper()
|
|
bodyBytes, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": map[string]any{"name": name, "arguments": args},
|
|
})
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(bodyBytes))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
return resp
|
|
}
|
|
|
|
func TestBrainQueryReturnsResults(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
knowledge := filepath.Join(brainDir, "knowledge")
|
|
require.NoError(t, os.MkdirAll(knowledge, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(knowledge, "tdd.md"),
|
|
[]byte("# TDD\n\nTest-driven development is iterative.\n"),
|
|
0o644,
|
|
))
|
|
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"})
|
|
|
|
require.Nil(t, resp["error"])
|
|
result := resp["result"].(map[string]any)
|
|
content := result["content"].([]any)
|
|
require.NotEmpty(t, content)
|
|
text := content[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, text, "tdd.md")
|
|
}
|
|
|
|
// TestBrainWriteIgnoresLegacyTypeAndDomainParams pins the post-#79 contract:
|
|
// domain/type were removed from the schema and the handler's arg struct, so
|
|
// a caller that still sends them (the old MCP surface) gets a normal wing+
|
|
// hall write with no error and no effect from the stale fields — silently
|
|
// dropped by json.Unmarshal, not rejected. Only content in its OWN
|
|
// frontmatter (unrelated to these params) can set type/domain, per
|
|
// TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking in the
|
|
// api package.
|
|
func TestBrainWriteIgnoresLegacyTypeAndDomainParams(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
"content": "# Test\n\nbody",
|
|
"filename": "test",
|
|
"wing": "homelab",
|
|
"hall": "facts",
|
|
"type": "note",
|
|
"domain": "personal",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "homelab", "facts", "test.md"))
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(got), "type: note")
|
|
assert.NotContains(t, string(got), "domain: personal")
|
|
assert.Contains(t, string(got), "# Test")
|
|
}
|
|
|
|
// TestBrainWriteRequiresWingAndHall is the MCP-tool-call counterpart of
|
|
// api.TestWriteNote_RequiresWingAndHall: the legacy brain/knowledge/
|
|
// fallback is gone, so a caller omitting either param gets a tool error,
|
|
// not a silent legacy write.
|
|
func TestBrainWriteRequiresWingAndHall(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
"content": "x",
|
|
"filename": "no-destination",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
"content": "# Val Vol\n\nbody",
|
|
"filename": "val-vol-r2",
|
|
"wing": "jepa-fx",
|
|
"hall": "decisions",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "jepa-fx", "decisions", "val-vol-r2.md"))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(got), "wing: jepa-fx")
|
|
assert.Contains(t, string(got), "hall: decisions")
|
|
assert.Contains(t, string(got), "created_at:")
|
|
assert.Contains(t, string(got), "# Val Vol")
|
|
}
|
|
|
|
func TestBrainWriteRejectsInvalidHall(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
"content": "x",
|
|
"wing": "jepa-fx",
|
|
"hall": "garbage",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
func TestBrainQueryWingScope(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
for _, p := range []struct{ rel, body string }{
|
|
{"wiki/jepa-fx/facts/x.md", "---\nwing: jepa-fx\nhall: facts\n---\nfoo keyword.\n"},
|
|
{"wiki/other/facts/y.md", "---\nwing: other\nhall: facts\n---\nfoo keyword.\n"},
|
|
} {
|
|
full := filepath.Join(brainDir, p.rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
|
}
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_query", map[string]any{
|
|
"query": "foo",
|
|
"wing": "jepa-fx",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, text, "wiki/jepa-fx/facts/x.md")
|
|
assert.NotContains(t, text, "wiki/other/facts/y.md")
|
|
}
|
|
|
|
func TestBrainWriteAutoTunnelsOnExactMatch(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
// Seed a pre-existing note in wing "other".
|
|
existing := filepath.Join(brainDir, "wiki/other/facts/widget.md")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(existing), 0o755))
|
|
require.NoError(t, os.WriteFile(existing,
|
|
[]byte("---\nwing: other\nhall: facts\ntitle: Widget\n---\nbody.\n"), 0o644))
|
|
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
// Write a new note in a *different* wing whose content references "Widget".
|
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
"content": "# Notes\n\nThis note discusses the Widget concept.\n",
|
|
"filename": "notes",
|
|
"wing": "jepa-fx",
|
|
"hall": "facts",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
|
|
newNote := filepath.Join(brainDir, "wiki/jepa-fx/facts/notes.md")
|
|
got, err := os.ReadFile(newNote)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(got), "[[other/facts/widget]]", "new note should link to existing")
|
|
|
|
gotTgt, err := os.ReadFile(existing)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(gotTgt), "[[jepa-fx/facts/notes]]", "existing note should backlink")
|
|
}
|
|
|
|
func TestBrainWriteAutoTunnelSkipsSameWing(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
existing := filepath.Join(brainDir, "wiki/jepa-fx/facts/widget.md")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(existing), 0o755))
|
|
require.NoError(t, os.WriteFile(existing,
|
|
[]byte("---\nwing: jepa-fx\nhall: facts\ntitle: Widget\n---\nbody.\n"), 0o644))
|
|
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
|
"content": "Same wing reference to Widget here.\n",
|
|
"filename": "notes",
|
|
"wing": "jepa-fx",
|
|
"hall": "facts",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
|
|
newNote := filepath.Join(brainDir, "wiki/jepa-fx/facts/notes.md")
|
|
got, err := os.ReadFile(newNote)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(got), "[[jepa-fx/facts/widget]]", "same-wing match must not auto-tunnel")
|
|
}
|
|
|
|
func TestBrainTunnelLinksTwoNotes(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
for _, p := range []struct{ rel, body string }{
|
|
{"wiki/jepa-fx/decisions/val-vol.md", "---\nwing: jepa-fx\nhall: decisions\n---\n# Val Vol\n"},
|
|
{"wiki/hyperguild/decisions/routing.md", "---\nwing: hyperguild\nhall: decisions\n---\n# Routing\n"},
|
|
} {
|
|
full := filepath.Join(brainDir, p.rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
|
|
}
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_tunnel", map[string]any{
|
|
"source": "wiki/jepa-fx/decisions/val-vol.md",
|
|
"target": "wiki/hyperguild/decisions/routing.md",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
|
|
src, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/decisions/val-vol.md"))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(src), "[[hyperguild/decisions/routing]]")
|
|
tgt, err := os.ReadFile(filepath.Join(brainDir, "wiki/hyperguild/decisions/routing.md"))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(tgt), "[[jepa-fx/decisions/val-vol]]")
|
|
}
|
|
|
|
func TestBrainTunnelRejectsMissing(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_tunnel", map[string]any{
|
|
"source": "wiki/a/facts/ghost.md",
|
|
"target": "wiki/b/facts/ghost.md",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
// Filename traversal/sanitisation on the wiki route is covered directly
|
|
// against brain.NotePath/Sanitise in internal/brain/path_test.go
|
|
// (TestNotePath_SanitisesWingAndSlug, TestSanitise) — those tests exercised
|
|
// the now-deleted brain/knowledge/ legacy path's own separate filename
|
|
// validation, which had no equivalent once that path was removed (#79).
|
|
|
|
func TestBrainIngestRawDryRun(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "concepts"), 0o755))
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_ingest_raw", map[string]any{
|
|
"source": "test-source",
|
|
"dry_run": true,
|
|
"pages": []map[string]any{
|
|
{
|
|
"title": "Test Concept",
|
|
"type": "concept",
|
|
"content": "## Definition\nA test concept.",
|
|
},
|
|
},
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
result := resp["result"].(map[string]any)
|
|
content := result["content"].([]any)
|
|
text := content[0].(map[string]any)["text"].(string)
|
|
|
|
var parsed struct {
|
|
Pages []string `json:"pages"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(text), &parsed))
|
|
require.NotEmpty(t, parsed.Pages, "expected at least one page path")
|
|
assert.Contains(t, parsed.Pages[0], "wiki/concepts/test-concept.md")
|
|
|
|
// dry_run: no file should exist
|
|
_, err := os.Stat(filepath.Join(brainDir, "wiki", "concepts", "test-concept.md"))
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|
|
|
|
func TestBrainIngestRejectsBoth(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
|
"content": "x",
|
|
"source": "y",
|
|
"path": "/tmp/foo.md",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
func TestBrainIngestRequiresOne(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
|
"content": "x",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil) // nil pipelineCfg → no LLM
|
|
|
|
resp := toolCall(t, srv, "brain_ingest", map[string]any{
|
|
"content": "some content",
|
|
"source": "test",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
errObj := resp["error"].(map[string]any)
|
|
assert.Contains(t, errObj["message"].(string), "LLM not configured")
|
|
}
|
|
|
|
func TestSessionLogAppends(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "session_log", map[string]any{
|
|
"session_id": "session-x",
|
|
"skill": "tdd",
|
|
"phase": "red",
|
|
"final_status": "ok",
|
|
})
|
|
require.Nil(t, resp["error"])
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, "sessions", "session-x.jsonl"))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(got), `"skill":"tdd"`)
|
|
assert.Contains(t, string(got), `"phase":"red"`)
|
|
}
|
|
|
|
func TestSessionLogRequiresSessionID(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
|
require.NotNil(t, resp["error"])
|
|
}
|
|
|
|
func TestBrainPendingListsRaw(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
raw := filepath.Join(brainDir, "raw")
|
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-x.md"),
|
|
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\npending body\n"), 0o644))
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
|
require.Nil(t, resp["error"])
|
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, text, "2026-06-01-x.md")
|
|
assert.Contains(t, text, "pending body")
|
|
}
|
|
|
|
func TestBrainPendingEmpty(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
|
require.Nil(t, resp["error"])
|
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, text, `"pending":[]`)
|
|
}
|
|
|
|
func TestBrainPromoteMovesToWiki(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
raw := filepath.Join(brainDir, "raw")
|
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-decision.md"),
|
|
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\n# D\n\nbody\n"), 0o644))
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
|
"filename": "2026-06-01-decision.md", "wing": "jepa-fx", "hall": "decisions",
|
|
})
|
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, text, "wiki/jepa-fx/decisions/decision.md")
|
|
|
|
_, err := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/decisions/decision.md"))
|
|
require.NoError(t, err)
|
|
_, srcErr := os.Stat(filepath.Join(raw, "2026-06-01-decision.md"))
|
|
assert.True(t, os.IsNotExist(srcErr), "source removed")
|
|
}
|
|
|
|
func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
raw := filepath.Join(brainDir, "raw")
|
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "x.md"), []byte("body\n"), 0o644))
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
|
"filename": "x.md", "wing": "a", "hall": "garbage",
|
|
})
|
|
require.NotNil(t, resp["error"])
|
|
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
|
assert.NoError(t, srcErr, "source untouched on validation error")
|
|
}
|
|
|
|
func TestBrainRejectMovesToRejectedSubdir(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
raw := filepath.Join(brainDir, "raw")
|
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-04-23-scrape.md"),
|
|
[]byte("---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Scrape\n\nbody\n"), 0o644))
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
|
|
resp := toolCall(t, srv, "brain_reject", map[string]any{
|
|
"filename": "2026-04-23-scrape.md", "reason": "generic scrape, not homelab-relevant",
|
|
})
|
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, text, "raw/rejected/2026-04-23-scrape.md")
|
|
|
|
got, err := os.ReadFile(filepath.Join(brainDir, "raw/rejected/2026-04-23-scrape.md"))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(got), "reject_reason: generic scrape, not homelab-relevant")
|
|
|
|
_, srcErr := os.Stat(filepath.Join(raw, "2026-04-23-scrape.md"))
|
|
assert.True(t, os.IsNotExist(srcErr), "source removed from raw/")
|
|
|
|
// A rejected note must not still show up as pending.
|
|
pendingResp := toolCall(t, srv, "brain_pending", map[string]any{})
|
|
pendingText := pendingResp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
|
assert.Contains(t, pendingText, `"pending":[]`)
|
|
}
|
|
|
|
func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
|
require.NotNil(t, resp["error"])
|
|
}
|