diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4b858250c..ce22116b8 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5914,7 +5914,7 @@ TEST(tool_query_graph_prefix_directory_recovers_rows_beyond_raw_estimate) { PASS(); } -TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { +TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-list-lean-XXXXXX", cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(cache)); @@ -5971,38 +5971,6 @@ TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { bool tree_not_duplicated = tree_response && !strstr(tree_response, "structuredContent"); bool page_truthful = page && strstr(page, "projects: 5") && strstr(page, "returned: 5") && strstr(page, "has_more: true") && strstr(page, "next_offset: 5"); - bool projects_resolve_after_switches = true; - for (int i = PROJECTS - 1; i >= 0; i--) { - char project[32]; - char args[96]; - snprintf(project, sizeof(project), "lean-project-%02d", i); - snprintf(args, sizeof(args), "{\"project\":\"%s\"}", project); - - char *status_response = cbm_mcp_handle_tool(srv, "index_status", args); - char *status = extract_text_content(status_response); - if (!status || !strstr(status, "ready")) { - projects_resolve_after_switches = false; - } - free(status); - free(status_response); - - snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\"entry\"}", - project); - char *search_response = cbm_mcp_handle_tool(srv, "search_graph", args); - char *search = extract_text_content(search_response); - if (!search || !strstr(search, "entry")) { - projects_resolve_after_switches = false; - } - free(search); - free(search_response); - } - char *post_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); - char *post_switch = extract_text_content(post_switch_response); - bool list_complete_after_switches = - post_switch && strstr(post_switch, "total: 12") && strstr(post_switch, "lean-project-00") && - strstr(post_switch, "lean-project-11"); - free(post_switch); - free(post_switch_response); bool json_direct = false; bool json_structured = json_response && strstr(json_response, "structuredContent"); yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL; @@ -6039,13 +6007,109 @@ TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { ASSERT_TRUE(tree_is_leaner); ASSERT_TRUE(tree_not_duplicated); ASSERT_TRUE(page_truthful); - ASSERT_TRUE(projects_resolve_after_switches); - ASSERT_TRUE(list_complete_after_switches); ASSERT_TRUE(json_direct); ASSERT_TRUE(json_structured); PASS(); } +/* #1115: with more indexed projects than were resident at once, the + * least-recently-used one answered "project not found or not indexed" from + * index_status, and list_projects and index_status disagreed in the same + * session. The server keeps one project store open and switches it whenever a + * call names another project, so twelve projects visited one after another + * force a switch on every step and each visit is the first touch of a project + * that is not resident. Every project must resolve through index_status and a + * query, and list_projects must report all twelve before and after the + * switches. */ +TEST(tool_project_discovery_agrees_across_store_switches_issue1115) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-switch-1115-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + enum { PROJECTS = 12 }; + for (int i = 0; i < PROJECTS; i++) { + char project[32]; + char db_path[512]; + char root_path[512]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(root_path, sizeof(root_path), + "/workspaces/organization/shared/services/lean-project-%02d", i); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, root_path), CBM_STORE_OK); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "entry", + .qualified_name = "shared.module.entry", + .file_path = "src/main.c", + .start_line = 1, + .end_line = 1}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + cbm_store_close(store); + } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *pre_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); + char *pre_switch = extract_text_content(pre_switch_response); + bool list_complete_before_switches = pre_switch && strstr(pre_switch, "total: 12") && + strstr(pre_switch, "lean-project-00") && + strstr(pre_switch, "lean-project-11"); + free(pre_switch); + free(pre_switch_response); + + bool projects_resolve_after_switches = true; + for (int i = PROJECTS - 1; i >= 0; i--) { + char project[32]; + char args[96]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + snprintf(args, sizeof(args), "{\"project\":\"%s\"}", project); + + char *status_response = cbm_mcp_handle_tool(srv, "index_status", args); + char *status = extract_text_content(status_response); + if (!status || !strstr(status, "ready")) { + projects_resolve_after_switches = false; + } + free(status); + free(status_response); + + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\"entry\"}", project); + char *search_response = cbm_mcp_handle_tool(srv, "search_graph", args); + char *search = extract_text_content(search_response); + if (!search || !strstr(search, "entry")) { + projects_resolve_after_switches = false; + } + free(search); + free(search_response); + } + char *post_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); + char *post_switch = extract_text_content(post_switch_response); + bool list_complete_after_switches = post_switch && strstr(post_switch, "total: 12") && + strstr(post_switch, "lean-project-00") && + strstr(post_switch, "lean-project-11"); + free(post_switch); + free(post_switch_response); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + for (int i = 0; i < PROJECTS; i++) { + char project[32]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + cleanup_project_db(cache, project); + } + cbm_rmdir(cache); + + ASSERT_TRUE(list_complete_before_switches); + ASSERT_TRUE(projects_resolve_after_switches); + ASSERT_TRUE(list_complete_after_switches); + PASS(); +} + /* The stored project root is dynamic identity, not a display-sized field. * Keep a syntactically valid segmented path beyond 1 KiB intact in both wire * encodings; the default detail also routes that same value through branch @@ -25105,7 +25169,8 @@ SUITE(mcp) { RUN_TEST(tool_query_graph_budget_bounds_first_row_and_json_escaping); RUN_TEST(tool_query_graph_prefix_directory_is_lossless_and_json_stays_direct); RUN_TEST(tool_query_graph_prefix_directory_recovers_rows_beyond_raw_estimate); - RUN_TEST(tool_list_projects_stays_complete_across_store_switches_issue1115); + RUN_TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct); + RUN_TEST(tool_project_discovery_agrees_across_store_switches_issue1115); RUN_TEST(tool_list_projects_preserves_root_beyond_one_kib); RUN_TEST(tool_index_status_no_project); RUN_TEST(tool_ingest_traces_stores_observed_calls);