remember / supersede / forget now reach the one Python writer from a terminal, from MCP, and from HTTP. The server, not the caller, decides who owns a memory. - cli: remember, supersede, forget. --holder falls back to settings.default_holder until the holder registry lands. Timestamps are parsed once with one clear message. - ingestion: import pymupdf as fitz. The deprecated fitz import printed a warning to stdout, so every CLI command's JSON answer was unparseable. - mcp: remember, supersede_memory and forget_memory forward to the Python writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite stays read-only to the Go side, so the write rules live in one place. - mcp reads follow schema v2: atoms.time became ingested_at, and reads go through v_atoms_current, so a superseded memory stops being findable. holder, origin, scenario and event_time now show up in fetch_atom and search_atoms. - scripts/mcp_write_smoke.sh proves the loop end to end: remember, search, supersede, forget, then not found. Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean on the 10 touched source files with a fresh cache; ruff clean except a pre-existing B008 at cli.py:292, identical at HEAD.
136 lines
4.7 KiB
Go
136 lines
4.7 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 recordingServer(t *testing.T, status int, 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)
|
|
var decoded map[string]any
|
|
_ = json.Unmarshal(payload, &decoded)
|
|
*seen = append(*seen, r.URL.Path+" "+string(payload)+" "+func() string {
|
|
if decoded["text"] == nil {
|
|
return "<no text>"
|
|
}
|
|
return decoded["text"].(string)
|
|
}())
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
}
|
|
|
|
func TestRememberSendsTheStatementAndReturnsTheStampedMemory(t *testing.T) {
|
|
var seen []string
|
|
server := recordingServer(t, http.StatusCreated,
|
|
`{"atom_id":"mem-1:text","source_id":"mem-1","holder_id":"husband","origin":"self","text":"钥匙在玄关抽屉"}`, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
_, out, err := s.rememberTool(context.Background(), nil, RememberInput{Text: "钥匙在玄关抽屉", Scenario: "home"})
|
|
if err != nil {
|
|
t.Fatalf("remember failed: %v", err)
|
|
}
|
|
if out.Atom.HolderID != "husband" || out.Atom.Origin != "self" {
|
|
t.Errorf("stamped memory = %+v, want the server's holder and origin", out.Atom)
|
|
}
|
|
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory") {
|
|
t.Fatalf("requests = %v, want one POST to /api/memory", seen)
|
|
}
|
|
if !strings.Contains(seen[0], "钥匙在玄关抽屉") {
|
|
t.Errorf("request = %s, want the statement verbatim", seen[0])
|
|
}
|
|
}
|
|
|
|
func TestRememberRefusesAnEmptyStatement(t *testing.T) {
|
|
var seen []string
|
|
server := recordingServer(t, http.StatusCreated, `{}`, &seen)
|
|
defer server.Close()
|
|
|
|
if _, _, err := (&Server{api: apiclient.New(server.URL)}).rememberTool(context.Background(), nil, RememberInput{}); err == nil {
|
|
t.Fatal("an empty memory must not be stored")
|
|
}
|
|
if len(seen) != 0 {
|
|
t.Errorf("requests = %v, want the empty write rejected before the network", seen)
|
|
}
|
|
}
|
|
|
|
func TestSupersedeNamesTheAtomItReplaces(t *testing.T) {
|
|
var seen []string
|
|
server := recordingServer(t, http.StatusOK,
|
|
`{"atom_id":"mem-2:text","supersedes":["mem-1:text"],"valid_from":"2026-05-02T00:00:00+00:00"}`, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
_, out, err := s.supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "钥匙在客厅茶几"})
|
|
if err != nil {
|
|
t.Fatalf("supersede failed: %v", err)
|
|
}
|
|
if len(out.Atom.Supersedes) != 1 || out.Atom.Supersedes[0] != "mem-1:text" {
|
|
t.Errorf("supersedes = %v, want the replaced atom", out.Atom.Supersedes)
|
|
}
|
|
if !strings.Contains(seen[0], "/api/memory/supersede") {
|
|
t.Errorf("request = %s, want the supersede route", seen[0])
|
|
}
|
|
}
|
|
|
|
func TestSupersedeRefusesAMissingAtomID(t *testing.T) {
|
|
var seen []string
|
|
server := recordingServer(t, http.StatusOK, `{}`, &seen)
|
|
defer server.Close()
|
|
|
|
if _, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{Text: "新的说法"}); err == nil {
|
|
t.Fatal("supersede must name the atom it replaces")
|
|
}
|
|
if len(seen) != 0 {
|
|
t.Errorf("requests = %v, want no write attempted", seen)
|
|
}
|
|
}
|
|
|
|
func TestForgetTakesExactlyOneID(t *testing.T) {
|
|
var seen []string
|
|
server := recordingServer(t, http.StatusOK, `{"source_id":"mem-1","atoms_deleted":1}`, &seen)
|
|
defer server.Close()
|
|
|
|
s := &Server{api: apiclient.New(server.URL)}
|
|
for _, input := range []ForgetInput{{}, {AtomID: "a:text", SourceID: "b"}} {
|
|
if _, _, err := s.forgetTool(context.Background(), nil, input); err == nil {
|
|
t.Errorf("input %+v accepted, want exactly one id", input)
|
|
}
|
|
}
|
|
_, out, err := s.forgetTool(context.Background(), nil, ForgetInput{SourceID: "mem-1", Reason: "moved house"})
|
|
if err != nil {
|
|
t.Fatalf("forget failed: %v", err)
|
|
}
|
|
if out.Report.AtomsDeleted != 1 {
|
|
t.Errorf("report = %+v, want the deleted count back", out.Report)
|
|
}
|
|
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory/forget") {
|
|
t.Errorf("requests = %v, want one POST to the forget route", seen)
|
|
}
|
|
}
|
|
|
|
func TestARejectedWriteReachesTheCallerAsAnError(t *testing.T) {
|
|
var seen []string
|
|
server := recordingServer(t, http.StatusUnprocessableEntity,
|
|
`{"detail":"mem-1:text was already closed"}`, &seen)
|
|
defer server.Close()
|
|
|
|
_, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "再说一次"})
|
|
if err == nil {
|
|
t.Fatal("a closed memory must not supersede silently")
|
|
}
|
|
if !strings.Contains(err.Error(), "already closed") {
|
|
t.Errorf("error = %v, want the server's reason", err)
|
|
}
|
|
}
|