Files
ely-eye/ely-eye-mcp/internal/mcpserver/directory_tools_test.go
T
ZacharyZhang-NY dfc6cd4c28 Give the system a directory: ask who knows, then ask them
A memory system that only answers from its own store is a filing cabinet,
not a partner. The transactive rule the project is named for — not knowing
is fine when you know who does — needed a holder registry, a directory
query, and a referral that never leaks the text it may not read.

- schema v5 adds `holders` (person/agent/service, declared topics, contact).
  A holder with memories exists whether or not anybody registered them; the
  registry adds reachability, it does not grant existence.
- `who_knows` ranks holders by their own memories and is deliberately NOT
  filtered by visibility: "she knows" is the answer. The answer is filtered.
- `ask` searches the asker's visible scope first (hop 0), then walks the
  directory with a visited set and a hop budget. Three terminal states:
  answered, referred, no_holder_knows. Nobody knowing is a result, not an error.
- Confidence is ordinal, not calibrated: relative strength, coverage, and
  declared topics. Every hit carries the raw numbers and the basis lines that
  produced the score, so a ranking can be argued with.
- Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders,
  holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders).

Four bugs found by running the thing, not by reading it:
- FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column.
- A CJK question is longer than the sentence that answers it, so the whole-run
  phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency
  is kept, equal length is not. Latin path untouched, baseline unaffected.
- The hop budget counted the asker as a hop, so max_hops=1 asked nobody.
- A second, stricter readability test in _answered() disagreed with the SQL
  visibility clause and hid a memory the asker was allowed to read. The SQL
  clause is the only authority now.

A method named `list` shadowed the builtin inside its own class and made
mypy reject the return annotation; renamed rather than cast around it.

Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright
ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals
on one SQLite file — the husband asks through MCP, gets `referred` with his
wife's contact channel, and her private sentence appears nowhere in the reply.
2026-10-07 17:20:45 -04:00

134 lines
5.0 KiB
Go

package mcpserver
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
)
func directoryServer(t *testing.T, body string, seen *[]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
*seen = append(*seen, r.Method+" "+r.URL.Path+" "+string(payload))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}))
}
func TestWhoKnowsNamesTheHolderAndKeepsTheEvidence(t *testing.T) {
var seen []string
server := directoryServer(t, `{"query":"钥匙","as_holder":"husband","status":"found","hits":[{
"holder_id":"wife","name":"小美","confidence":0.91,"matched":2,"top_score":0.02,
"basis":["wife holds 2 matching memories the asker may not read"],"contact":{"channel":"signal"}}]}`, &seen)
defer server.Close()
_, out, err := (&Server{api: apiclient.New(server.URL)}).whoKnows(
context.Background(), nil, WhoKnowsInput{Query: "钥匙", AsHolder: "husband"})
if err != nil {
t.Fatalf("who_knows failed: %v", err)
}
if out.Report.Status != "found" || len(out.Report.Hits) != 1 {
t.Fatalf("report = %+v, want one found candidate", out.Report)
}
hit := out.Report.Hits[0]
if hit.HolderID != "wife" || hit.Confidence != 0.91 || hit.Contact["channel"] != "signal" {
t.Errorf("hit = %+v, want the holder, her confidence, and how to reach her", hit)
}
if len(seen) != 1 || !strings.Contains(seen[0], "POST /api/who_knows") || !strings.Contains(seen[0], `"query":"钥匙"`) {
t.Fatalf("requests = %v, want one POST carrying the query", seen)
}
}
func TestWhoKnowsRefusesAnEmptyQueryBeforeTheNetwork(t *testing.T) {
var seen []string
server := directoryServer(t, `{}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).whoKnows(context.Background(), nil, WhoKnowsInput{}); err == nil {
t.Fatal("an empty query must not reach the directory")
}
if len(seen) != 0 {
t.Errorf("requests = %v, want none", seen)
}
}
func TestAskReturnsThePointerAndNotThePrivateText(t *testing.T) {
var seen []string
server := directoryServer(t, `{"question":"钥匙在哪里","as_holder":"husband","status":"referred",
"answered_by":"wife","asked":["husband","wife"],"hops":1,"atom_ids":[],"basis":["wife holds 1 matching memories the asker may not read"]}`, &seen)
defer server.Close()
_, out, err := (&Server{api: apiclient.New(server.URL)}).askMemory(
context.Background(), nil, AskInput{Question: "钥匙在哪里", AsHolder: "husband", MaxHops: 2})
if err != nil {
t.Fatalf("ask failed: %v", err)
}
if out.Answer.Status != "referred" || out.Answer.Answer != "" {
t.Errorf("answer = %+v, want a referral with no text", out.Answer)
}
if out.Answer.AnsweredBy != "wife" || out.Answer.Hops != 1 {
t.Errorf("answer = %+v, want wife at hop 1", out.Answer)
}
if !strings.Contains(seen[0], `"max_hops":2`) || !strings.Contains(seen[0], `"as_holder":"husband"`) {
t.Errorf("request = %s, want the hop budget and the asker", seen[0])
}
}
func TestAskWithoutAnAskerOmitsTheIdentity(t *testing.T) {
var seen []string
server := directoryServer(t, `{"question":"garage code","status":"no_holder_knows","asked":[],"hops":0}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).askMemory(context.Background(), nil, AskInput{Question: "garage code"}); err != nil {
t.Fatalf("ask failed: %v", err)
}
var body map[string]any
parts := strings.SplitN(seen[0], " ", 3)
if err := json.Unmarshal([]byte(parts[2]), &body); err != nil {
t.Fatalf("request body is not JSON: %s", seen[0])
}
if _, present := body["as_holder"]; present {
t.Errorf("body = %v, want as_holder omitted so the server applies its own default", body)
}
if _, present := body["max_hops"]; present {
t.Errorf("body = %v, want max_hops omitted; a Go zero is not a hop budget", body)
}
}
func TestAskRefusesAnEmptyQuestion(t *testing.T) {
var seen []string
server := directoryServer(t, `{}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).askMemory(context.Background(), nil, AskInput{}); err == nil {
t.Fatal("an empty question must not be asked")
}
if len(seen) != 0 {
t.Errorf("requests = %v, want none", seen)
}
}
func TestListHoldersReadsTheRegistry(t *testing.T) {
var seen []string
server := directoryServer(t, `[{"holder_id":"wife","name":"小美","kind":"person","knows_about":["家里的钥匙"],"contact":{"channel":"signal"},"created_at":"2026-05-01T00:00:00+00:00","updated_at":"2026-05-01T00:00:00+00:00"}]`, &seen)
defer server.Close()
_, out, err := (&Server{api: apiclient.New(server.URL)}).listHolders(context.Background(), nil, struct{}{})
if err != nil {
t.Fatalf("list_holders failed: %v", err)
}
if len(out.Holders) != 1 || out.Holders[0].KnowsAbout[0] != "家里的钥匙" {
t.Fatalf("holders = %+v, want the declared topics", out.Holders)
}
if len(seen) != 1 || !strings.HasPrefix(seen[0], "GET /api/holders") {
t.Errorf("requests = %v, want one GET", seen)
}
}