molecule-core/docs/memory-plugins/testing-your-plugin.md
claude-ceo-assistant 3501e6bfd7
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 13s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 11s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 12s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 15s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 27s
CI / Detect changes (pull_request) Successful in 20s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 51s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 51s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 39s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 51s
Harness Replays / detect-changes (pull_request) Successful in 53s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 48s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 31s
Harness Replays / Harness Replays (pull_request) Failing after 1m18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m19s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6m47s
CI / Python Lint & Test (pull_request) Successful in 8m16s
CI / Canvas (Next.js) (pull_request) Failing after 9m36s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 12m18s
fix(post-suspension): vanity import paths go.moleculesai.app/core/{platform,tests/harness/cp-stub} (closes molecule-ai/internal#71 phase 2)
Migrates the two Go modules under molecule-core off the dead
github.com/Molecule-AI/molecule-monorepo/... identity onto the vanity
host go.moleculesai.app. Also fixes the historical naming
inconsistency where the Gitea repo is molecule-core but the Go module
path said molecule-monorepo.

Module changes:
- workspace-server/go.mod:
    github.com/Molecule-AI/molecule-monorepo/platform
    -> go.moleculesai.app/core/platform
- tests/harness/cp-stub/go.mod:
    github.com/Molecule-AI/molecule-monorepo/tests/harness/cp-stub
    -> go.moleculesai.app/core/tests/harness/cp-stub

Surfaces touched
- 174 *.go files (374 import lines) — every import under
  workspace-server/ + tests/harness/cp-stub/
- 2 Dockerfiles (workspace-server/Dockerfile + Dockerfile.tenant) —
  -ldflags strings updated in lockstep with the module rename so
  buildinfo.GitSHA injection still resolves correctly
- README + docs + scripts + comment URLs to git.moleculesai.app form
- NEW workspace-server/internal/lint/import_path_lint_test.go —
  structural lint gate rejecting future github.com/Molecule-AI/ or
  Molecule-AI/molecule-monorepo references. Identical template to the
  other migration PRs (plugin-gh-identity#3, molecule-cli#2,
  molecule-controlplane#32).

Cross-repo dep allowlist (documented in lint gate)
workspace-server requires molecule-ai-plugin-gh-identity, whose own
vanity migration is PR molecule-ai-plugin-gh-identity#3. Until that PR
merges + a tag is cut at go.moleculesai.app/plugin/gh-identity, the
two locations referencing the legacy github.com path
(workspace-server/go.mod require, cmd/server/main.go import) remain
allowlisted. Follow-up PR drops the allowlist + updates both refs in
one shot once gh-identity is fully migrated.

Test plan
- go build ./... clean for both modules
- go test ./... green except two pre-existing failures
  (TestStartSweeper_RecordsMetricsOnSuccess flaky-on-suite,
  TestLocalResolver_BubblesUpCopyFailure relies on read-only fs perms
  but runs as root on operator host) — both reproduce identically on
  baseline main pre-migration; NOT regressions of this PR
- Mutation-tested: lint gate fails on canaries in .go + .md;
  allowlist correctly suppresses cross-repo dep references in go.mod
  while still flagging unrelated additions

Open dependency
- go.moleculesai.app responder must be deployed before fresh-clone
  external builds resolve the vanity path. Existing CI / Docker builds
  ride pinned go.sum + self-referential module path + responder is
  not on critical path for those.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:37:42 +00:00

182 lines
5.9 KiB
Markdown

# Testing Your Memory Plugin
Once you have a plugin implementing the v1 contract, you can validate
it against the spec without booting workspace-server.
## The contract test harness
Workspace-server ships typed Go bindings + round-trip tests in
`workspace-server/internal/memory/contract/`. The simplest way to
gain confidence in your plugin's wire compatibility is to point those
tests at it.
A minimal contract suite:
```go
package myplugin_test
import (
"context"
"testing"
mclient "github.com/go.moleculesai.app/core/platform/internal/memory/client"
"github.com/go.moleculesai.app/core/platform/internal/memory/contract"
)
func TestMyPlugin_FullRoundTrip(t *testing.T) {
// Start your plugin somehow (subprocess, in-process, etc.)
pluginURL := startMyPlugin(t)
cl := mclient.New(mclient.Config{BaseURL: pluginURL})
// 1. Health
hr, err := cl.Boot(context.Background())
if err != nil {
t.Fatalf("Boot: %v", err)
}
if hr.Status != "ok" {
t.Errorf("status = %q", hr.Status)
}
// 2. Namespace upsert
if _, err := cl.UpsertNamespace(context.Background(), "workspace:test-1",
contract.NamespaceUpsert{Kind: contract.NamespaceKindWorkspace}); err != nil {
t.Fatalf("UpsertNamespace: %v", err)
}
// 3. Commit memory
resp, err := cl.CommitMemory(context.Background(), "workspace:test-1",
contract.MemoryWrite{
Content: "hello",
Kind: contract.MemoryKindFact,
Source: contract.MemorySourceAgent,
})
if err != nil {
t.Fatalf("CommitMemory: %v", err)
}
if resp.ID == "" {
t.Errorf("plugin must return a non-empty memory id")
}
// 4. Search
sresp, err := cl.Search(context.Background(), contract.SearchRequest{
Namespaces: []string{"workspace:test-1"},
Query: "hello",
})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(sresp.Memories) == 0 {
t.Errorf("plugin returned no memories for the query we just wrote")
}
// 5. Forget
if err := cl.ForgetMemory(context.Background(), resp.ID,
contract.ForgetRequest{RequestedByNamespace: "workspace:test-1"}); err != nil {
t.Errorf("ForgetMemory: %v", err)
}
}
```
## Testing idempotency
The contract requires that `MemoryWrite.id`, when supplied, behaves
as an upsert key. The backfill CLI relies on this — without it,
operator retries silently duplicate every memory.
```go
func TestMyPlugin_IDIsIdempotencyKey(t *testing.T) {
pluginURL := startMyPlugin(t)
cl := mclient.New(mclient.Config{BaseURL: pluginURL})
if _, err := cl.UpsertNamespace(context.Background(), "workspace:test-1",
contract.NamespaceUpsert{Kind: contract.NamespaceKindWorkspace}); err != nil {
t.Fatal(err)
}
fixedID := "11111111-2222-3333-4444-555555555555"
// First write with a specific id.
resp1, err := cl.CommitMemory(context.Background(), "workspace:test-1",
contract.MemoryWrite{
ID: fixedID,
Content: "first version",
Kind: contract.MemoryKindFact,
Source: contract.MemorySourceAgent,
})
if err != nil {
t.Fatalf("first commit: %v", err)
}
if resp1.ID != fixedID {
t.Errorf("plugin must echo the supplied id, got %q", resp1.ID)
}
// Second write with the same id — must update, not insert.
if _, err := cl.CommitMemory(context.Background(), "workspace:test-1",
contract.MemoryWrite{
ID: fixedID,
Content: "second version (updated)",
Kind: contract.MemoryKindFact,
Source: contract.MemorySourceAgent,
}); err != nil {
t.Fatalf("second commit: %v", err)
}
// Search must return exactly one row, with the updated content.
sresp, _ := cl.Search(context.Background(), contract.SearchRequest{
Namespaces: []string{"workspace:test-1"},
})
matches := 0
for _, m := range sresp.Memories {
if m.ID == fixedID {
matches++
if m.Content != "second version (updated)" {
t.Errorf("upsert didn't update content: got %q", m.Content)
}
}
}
if matches != 1 {
t.Errorf("upsert produced %d rows for id=%s, want 1", matches, fixedID)
}
}
```
## What the harness does NOT cover
- **Capability accuracy**: if you list `embedding` you must actually
do semantic search. The harness can't tell you whether ranking is
meaningful — only that you don't crash.
- **TTL eviction**: write a memory with `expires_at` 1 second in the
future, sleep 2 seconds, search — assert the memory is gone.
- **Concurrency**: hit your plugin with 100 parallel writes; assert
no IDs collide.
- **Recovery**: kill your plugin's storage backend, send a request,
assert your plugin returns 503 (not 200 with stale data).
- **Backfill compatibility**: run the operator backfill against your
plugin twice in a row (`memory-backfill -apply`); assert the row
count doesn't double. The idempotency test above verifies the unit
contract; this checks the operational integration.
- **Verify-mode parity**: after a backfill, run `memory-backfill
-verify`; assert it reports zero mismatches against
`agent_memories`.
## Smoke test against workspace-server
Once unit-level wire tests pass, run a real workspace-server with your
plugin URL:
```bash
DATABASE_URL=postgres://... \
MEMORY_PLUGIN_URL=http://localhost:9100 \
./workspace-server
```
Then ask an agent to call `commit_memory_v2` and `search_memory`. If
both round-trip cleanly, you're done.
For the full E2E flow (including the namespace resolver, MCP layer,
and security perimeter), see [PR-11's plugin-swap test](../../workspace-server/test/e2e/memory_plugin_swap_test.go).
## Reporting bugs
If you find a contract ambiguity or missing edge case, file an issue
against `Molecule-AI/molecule-core` referencing RFC #2728.