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.
134 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|