Why: the completion doc assigns the Memory port to the library; today memory recall is a product-side interface assertion with no library surface, AgentSpec.MemoryScope has no consumer, and the model has no write-back path. Evidence: GOWORK=off go vet + go test -count=1 ./... green; TestLoopInjectsMemoryAndRegistersWrite (recall block lands after the system section, memory_write executes through the loop), TestEmptyRecallInjectsNothing (empty recall changes nothing), TestBlockRenderOrderingAndCaps (deterministic order, expiry/empty pruning), TestWriteToolValidation (fail-closed scope/type/content). Impact: new opt-in Config.Memory/MemoryScope; nil port keeps behavior byte-identical; injection is request-scoped (never persisted, never accumulates) and deterministic ([记忆] text shape preserved for the product migration). Refs: Agent-System-Documents#43 (N3 plan WP5)
92 lines
2.8 KiB
Go
92 lines
2.8 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ---- N3 WP5:记忆端口 ----
|
||
|
||
type fakeStore struct {
|
||
entries []Entry
|
||
appended []Entry
|
||
recallQ RecallQuery
|
||
err error
|
||
}
|
||
|
||
func (f *fakeStore) Recall(_ context.Context, q RecallQuery) ([]Entry, error) {
|
||
f.recallQ = q
|
||
return f.entries, f.err
|
||
}
|
||
func (f *fakeStore) Append(_ context.Context, e Entry) error {
|
||
f.appended = append(f.appended, e)
|
||
return nil
|
||
}
|
||
|
||
func TestBlockRenderOrderingAndCaps(t *testing.T) {
|
||
future := time.Now().Add(24 * time.Hour)
|
||
past := time.Now().Add(-24 * time.Hour)
|
||
entries := []Entry{
|
||
{Type: "preference", Content: "喜欢简洁回复"},
|
||
{Type: "fact", Content: "项目用 Go 1.27"},
|
||
{Type: "fact", Content: " "},
|
||
{Type: "fact", Content: "过期了", ExpiresAt: &past},
|
||
{Type: "fact", Content: "没过期", ExpiresAt: &future},
|
||
}
|
||
out := Block(entries, time.Now())
|
||
if !strings.Contains(out, "[记忆]\n") {
|
||
t.Fatalf("缺块头:%q", out)
|
||
}
|
||
// 确定性排序:Type 字典序(fact 先于 preference);过期与空内容剔除。
|
||
firstFact := strings.Index(out, "fact")
|
||
firstPref := strings.Index(out, "preference")
|
||
if firstFact < 0 || firstPref < 0 || firstFact > firstPref {
|
||
t.Fatalf("排序不确定:%q", out)
|
||
}
|
||
if strings.Contains(out, "过期了") {
|
||
t.Fatalf("过期条目未剔除:%q", out)
|
||
}
|
||
if !strings.Contains(out, "没过期") {
|
||
t.Fatalf("未过期条目被误剔:%q", out)
|
||
}
|
||
if Block(nil, time.Now()) != "" {
|
||
t.Fatal("空结果应为空串")
|
||
}
|
||
}
|
||
|
||
func TestWriteToolValidation(t *testing.T) {
|
||
store := &fakeStore{}
|
||
tool := &writeTool{store: store}
|
||
cases := []struct {
|
||
name string
|
||
args string
|
||
wantErr string
|
||
}{
|
||
{"坏 scope", `{"scope":"global","type":"fact","content":"x"}`, "user 或 project"},
|
||
{"坏 type", `{"type":"todo","content":"x"}`, "fact|preference"},
|
||
{"空 content", `{"type":"fact","content":" "}`, "非空"},
|
||
{"超长 content", `{"type":"fact","content":"` + strings.Repeat("长", 2001) + `"}`, "上限"},
|
||
}
|
||
for _, tc := range cases {
|
||
res, err := tool.Execute(context.Background(), json.RawMessage(tc.args))
|
||
if err != nil || !res.IsError || !strings.Contains(res.Content, tc.wantErr) {
|
||
t.Fatalf("%s:%+v %v", tc.name, res, err)
|
||
}
|
||
}
|
||
if len(store.appended) != 0 {
|
||
t.Fatalf("校验失败不得落库:%+v", store.appended)
|
||
}
|
||
// 合法路径:默认 scope=user + 过期换算。
|
||
res, err := tool.Execute(context.Background(), json.RawMessage(`{"type":"preference","content":"喜欢简洁","expires_in_days":30}`))
|
||
if err != nil || res.IsError {
|
||
t.Fatalf("合法写入被拒:%+v %v", res, err)
|
||
}
|
||
if len(store.appended) != 1 || store.appended[0].Scope != "user" ||
|
||
store.appended[0].Type != "preference" || store.appended[0].ExpiresAt == nil {
|
||
t.Fatalf("写入形态:%+v", store.appended)
|
||
}
|
||
}
|