|
9 | 9 |
|
10 | 10 | from basic_memory import db |
11 | 11 | from basic_memory.mcp.tools import list_memory_projects, create_memory_project, delete_project |
12 | | -from basic_memory.mcp.tools.project_management import _merge_projects |
| 12 | +from basic_memory.config import BasicMemoryConfig, ProjectEntry |
| 13 | +from basic_memory.mcp.tools.project_management import _merge_projects, _merge_workspace_projects |
13 | 14 | from basic_memory.models.project import Project |
14 | 15 | from basic_memory.schemas.project_info import ProjectItem, ProjectList |
15 | 16 |
|
@@ -909,6 +910,220 @@ def test_merge_projects_overlap(): |
909 | 910 | assert merged[0]["workspace_tenant_id"] == "org-456" |
910 | 911 |
|
911 | 912 |
|
| 913 | +def test_merge_workspace_projects_attaches_local_state_to_one_duplicate_workspace(tmp_path): |
| 914 | + """A same-name team workspace project should stay cloud-only (#848).""" |
| 915 | + local_path = str(tmp_path / "main") |
| 916 | + local_main = _make_project("main", local_path, is_default=True) |
| 917 | + local_list = _make_list([local_main], default="main") |
| 918 | + personal_main = _make_project( |
| 919 | + "main", |
| 920 | + "/cloud/personal-main", |
| 921 | + id=10, |
| 922 | + external_id="personal-main-uuid", |
| 923 | + ) |
| 924 | + team_main = _make_project( |
| 925 | + "main", |
| 926 | + "/cloud/team-main", |
| 927 | + id=11, |
| 928 | + external_id="team-main-uuid", |
| 929 | + ) |
| 930 | + personal_ws = _make_workspace( |
| 931 | + "personal-tenant", |
| 932 | + "Personal", |
| 933 | + slug="personal", |
| 934 | + is_default=True, |
| 935 | + ) |
| 936 | + team_ws = _make_workspace( |
| 937 | + "team-tenant", |
| 938 | + "Team", |
| 939 | + workspace_type="organization", |
| 940 | + slug="team", |
| 941 | + ) |
| 942 | + workspace_index = _make_workspace_index( |
| 943 | + [ |
| 944 | + (personal_ws, [personal_main]), |
| 945 | + (team_ws, [team_main]), |
| 946 | + ] |
| 947 | + ) |
| 948 | + config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)}) |
| 949 | + |
| 950 | + merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config) |
| 951 | + |
| 952 | + by_qualified_name = {project["qualified_name"]: project for project in merged} |
| 953 | + personal_project = by_qualified_name["personal/main"] |
| 954 | + team_project = by_qualified_name["team/main"] |
| 955 | + |
| 956 | + assert personal_project["source"] == "local+cloud" |
| 957 | + assert personal_project["local_path"] == local_path |
| 958 | + assert personal_project["path"] == local_path |
| 959 | + assert team_project["source"] == "cloud" |
| 960 | + assert team_project["local_path"] is None |
| 961 | + assert team_project["path"] == "/cloud/team-main" |
| 962 | + |
| 963 | + |
| 964 | +def test_merge_workspace_projects_uses_configured_workspace_for_local_state(tmp_path): |
| 965 | + """Per-project workspace_id should select the attached duplicate row.""" |
| 966 | + local_path = str(tmp_path / "main") |
| 967 | + local_main = _make_project("main", local_path, is_default=True) |
| 968 | + local_list = _make_list([local_main], default="main") |
| 969 | + personal_main = _make_project( |
| 970 | + "main", |
| 971 | + "/cloud/personal-main", |
| 972 | + id=10, |
| 973 | + external_id="personal-main-uuid", |
| 974 | + ) |
| 975 | + team_main = _make_project( |
| 976 | + "main", |
| 977 | + "/cloud/team-main", |
| 978 | + id=11, |
| 979 | + external_id="team-main-uuid", |
| 980 | + ) |
| 981 | + personal_ws = _make_workspace( |
| 982 | + "personal-tenant", |
| 983 | + "Personal", |
| 984 | + slug="personal", |
| 985 | + is_default=True, |
| 986 | + ) |
| 987 | + team_ws = _make_workspace( |
| 988 | + "team-tenant", |
| 989 | + "Team", |
| 990 | + workspace_type="organization", |
| 991 | + slug="team", |
| 992 | + ) |
| 993 | + workspace_index = _make_workspace_index( |
| 994 | + [ |
| 995 | + (personal_ws, [personal_main]), |
| 996 | + (team_ws, [team_main]), |
| 997 | + ] |
| 998 | + ) |
| 999 | + config = BasicMemoryConfig( |
| 1000 | + projects={ |
| 1001 | + "main": ProjectEntry( |
| 1002 | + path=local_path, |
| 1003 | + workspace_id="team-tenant", |
| 1004 | + ) |
| 1005 | + } |
| 1006 | + ) |
| 1007 | + |
| 1008 | + merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config) |
| 1009 | + |
| 1010 | + by_qualified_name = {project["qualified_name"]: project for project in merged} |
| 1011 | + personal_project = by_qualified_name["personal/main"] |
| 1012 | + team_project = by_qualified_name["team/main"] |
| 1013 | + |
| 1014 | + assert personal_project["source"] == "cloud" |
| 1015 | + assert personal_project["local_path"] is None |
| 1016 | + assert personal_project["path"] == "/cloud/personal-main" |
| 1017 | + assert team_project["source"] == "local+cloud" |
| 1018 | + assert team_project["local_path"] == local_path |
| 1019 | + assert team_project["path"] == local_path |
| 1020 | + |
| 1021 | + |
| 1022 | +def test_merge_workspace_projects_uses_default_workspace_for_local_state(tmp_path): |
| 1023 | + """Global default_workspace should attach local state before cloud default fallback.""" |
| 1024 | + local_path = str(tmp_path / "main") |
| 1025 | + local_main = _make_project("main", local_path, is_default=True) |
| 1026 | + local_list = _make_list([local_main], default="main") |
| 1027 | + personal_main = _make_project( |
| 1028 | + "main", |
| 1029 | + "/cloud/personal-main", |
| 1030 | + id=10, |
| 1031 | + external_id="personal-main-uuid", |
| 1032 | + ) |
| 1033 | + team_main = _make_project( |
| 1034 | + "main", |
| 1035 | + "/cloud/team-main", |
| 1036 | + id=11, |
| 1037 | + external_id="team-main-uuid", |
| 1038 | + ) |
| 1039 | + personal_ws = _make_workspace( |
| 1040 | + "personal-tenant", |
| 1041 | + "Personal", |
| 1042 | + slug="personal", |
| 1043 | + is_default=True, |
| 1044 | + ) |
| 1045 | + team_ws = _make_workspace( |
| 1046 | + "team-tenant", |
| 1047 | + "Team", |
| 1048 | + workspace_type="organization", |
| 1049 | + slug="team", |
| 1050 | + ) |
| 1051 | + workspace_index = _make_workspace_index( |
| 1052 | + [ |
| 1053 | + (personal_ws, [personal_main]), |
| 1054 | + (team_ws, [team_main]), |
| 1055 | + ] |
| 1056 | + ) |
| 1057 | + config = BasicMemoryConfig( |
| 1058 | + projects={"main": ProjectEntry(path=local_path)}, |
| 1059 | + default_workspace="team-tenant", |
| 1060 | + ) |
| 1061 | + |
| 1062 | + merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config) |
| 1063 | + |
| 1064 | + by_qualified_name = {project["qualified_name"]: project for project in merged} |
| 1065 | + personal_project = by_qualified_name["personal/main"] |
| 1066 | + team_project = by_qualified_name["team/main"] |
| 1067 | + |
| 1068 | + assert personal_project["source"] == "cloud" |
| 1069 | + assert personal_project["local_path"] is None |
| 1070 | + assert personal_project["path"] == "/cloud/personal-main" |
| 1071 | + assert team_project["source"] == "local+cloud" |
| 1072 | + assert team_project["local_path"] == local_path |
| 1073 | + assert team_project["path"] == local_path |
| 1074 | + |
| 1075 | + |
| 1076 | +def test_merge_workspace_projects_sorted_fallback_attaches_personal_workspace(tmp_path): |
| 1077 | + """When config has no preference and no cloud default exists, use stable priority.""" |
| 1078 | + local_path = str(tmp_path / "main") |
| 1079 | + local_main = _make_project("main", local_path, is_default=True) |
| 1080 | + local_list = _make_list([local_main], default="main") |
| 1081 | + personal_main = _make_project( |
| 1082 | + "main", |
| 1083 | + "/cloud/personal-main", |
| 1084 | + id=10, |
| 1085 | + external_id="personal-main-uuid", |
| 1086 | + ) |
| 1087 | + team_main = _make_project( |
| 1088 | + "main", |
| 1089 | + "/cloud/team-main", |
| 1090 | + id=11, |
| 1091 | + external_id="team-main-uuid", |
| 1092 | + ) |
| 1093 | + personal_ws = _make_workspace( |
| 1094 | + "personal-tenant", |
| 1095 | + "Personal", |
| 1096 | + slug="personal", |
| 1097 | + is_default=False, |
| 1098 | + ) |
| 1099 | + team_ws = _make_workspace( |
| 1100 | + "team-tenant", |
| 1101 | + "Team", |
| 1102 | + workspace_type="organization", |
| 1103 | + slug="team", |
| 1104 | + ) |
| 1105 | + workspace_index = _make_workspace_index( |
| 1106 | + [ |
| 1107 | + (team_ws, [team_main]), |
| 1108 | + (personal_ws, [personal_main]), |
| 1109 | + ] |
| 1110 | + ) |
| 1111 | + config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)}) |
| 1112 | + |
| 1113 | + merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config) |
| 1114 | + |
| 1115 | + by_qualified_name = {project["qualified_name"]: project for project in merged} |
| 1116 | + personal_project = by_qualified_name["personal/main"] |
| 1117 | + team_project = by_qualified_name["team/main"] |
| 1118 | + |
| 1119 | + assert personal_project["source"] == "local+cloud" |
| 1120 | + assert personal_project["local_path"] == local_path |
| 1121 | + assert personal_project["path"] == local_path |
| 1122 | + assert team_project["source"] == "cloud" |
| 1123 | + assert team_project["local_path"] is None |
| 1124 | + assert team_project["path"] == "/cloud/team-main" |
| 1125 | + |
| 1126 | + |
912 | 1127 | # --- Workspace passthrough tests --- |
913 | 1128 |
|
914 | 1129 |
|
|
0 commit comments