package sessionread import ( "errors" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // mainFake is the fixture the dispatcher-level tests read through: an existing main // session carrying two messages. func mainFake() *fakeHistory { return &fakeHistory{ sessions: []history.SessionMeta{{ID: history.MainSessionID, Title: "调试日志"}}, msgs: map[string][]history.Message{history.MainSessionID: { {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "在的", PersonaID: "yuki", CreatedAt: at(21, 7)}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "你好", CreatedAt: at(21, 4)}, }}, } } // TestNameIsSessionRead locks the registered tool name design/22 §8.1 fixes. func TestNameIsSessionRead(t *testing.T) { t.Parallel() if got := New().Name(); got != "session_read" { t.Fatalf("Name() = %q, want session_read", got) } } // TestParametersSchema locks the argument contract: sessionId is required, limit and // before are optional numbers, and limit deliberately declares no maximum — a value // above the cap must be clamped by the tool rather than rejected by the validator as // a malformed call. func TestParametersSchema(t *testing.T) { t.Parallel() params, err := New().Parameters() if err != nil { t.Fatalf("Parameters: %v", err) } if params["type"] != "object" { t.Fatalf("schema type = %v, want object", params["type"]) } required, ok := params["required"].([]any) if !ok || len(required) != 1 || required[0] != "sessionId" { t.Fatalf("required = %#v, want [sessionId]", params["required"]) } props, ok := params["properties"].(map[string]any) if !ok { t.Fatalf("properties = %#v, want an object", params["properties"]) } for _, name := range []string{"sessionId", "limit", "before"} { prop, ok := props[name].(map[string]any) if !ok { t.Fatalf("property %q missing from the schema", name) } if desc, _ := prop["description"].(string); strings.TrimSpace(desc) == "" { t.Fatalf("property %q has no description; the model cannot use it", name) } } if limit := props["limit"].(map[string]any); limit["type"] != "integer" { t.Fatalf("limit type = %v, want integer", limit["type"]) } else if _, bounded := limit["maximum"]; bounded { t.Fatal("limit declares a maximum; an over-cap value must be clamped, not rejected") } if before := props["before"].(map[string]any); before["type"] != "integer" { t.Fatalf("before type = %v, want integer", before["type"]) } } // TestDescriptionMarksTheMaterialAsExternal locks the wording the model sees: it has // to say the result is a read-only transcript of SOMEBODY ELSE's session, because // that label is what keeps her from quoting it as her own memory (design/22 §8-3). func TestDescriptionMarksTheMaterialAsExternal(t *testing.T) { t.Parallel() desc := New().Description() for _, want := range []string{"read-only", "your own memory", "web:"} { if !strings.Contains(desc, want) { t.Fatalf("Description() = %q, want it to mention %q", desc, want) } } } // TestDispatcherInvokesTheTool drives the tool through the real dispatcher, which // validates the arguments against Parameters() and coerces a decoded-JSON number // (float64) to the schema's integer. It is the closest thing to the model's own // call path that a unit test can exercise. func TestDispatcherInvokesTheTool(t *testing.T) { t.Parallel() reg := registry.New() d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } if err := d.Register(newReader(mainFake(), testPersonas())); err != nil { t.Fatalf("register: %v", err) } out, err := d.Invoke(ownerCtx(), ToolName, map[string]any{"sessionId": "web", "limit": float64(1)}) if err != nil { t.Fatalf("Invoke: %v", err) } text, ok := out.(string) if !ok { t.Fatalf("Invoke returned %T, want a string material", out) } if !strings.Contains(text, "在的") || strings.Contains(text, "你好") { t.Fatalf("material = %q, want only the newest message (limit 1 via float64)", text) } } // TestDispatcherRejectsNoSessionID asserts the schema's required field is enforced // before Execute, so a target-less call never reaches the reader. func TestDispatcherRejectsNoSessionID(t *testing.T) { t.Parallel() reg := registry.New() d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } h := mainFake() if err := d.Register(newReader(h, testPersonas())); err != nil { t.Fatalf("register: %v", err) } if _, err := d.Invoke(ownerCtx(), ToolName, map[string]any{}); !errors.Is(err, tool.ErrInvalidArgs) { t.Fatalf("err = %v, want ErrInvalidArgs", err) } if h.listCalls != 0 { t.Fatalf("history was read %d times for a target-less call, want 0", h.listCalls) } }