#include "glib.h" #include "prof_cmocka.h" #include "common.h" #include "ai/ai_client.h" #include "config/preferences.h" #include "helpers.h" #include #include /* ======================================================================== * Setup/Teardown * ======================================================================== */ int ai_client_setup(void** state) { ai_client_init(); return 0; } int ai_client_teardown(void** state) { ai_client_shutdown(); return 0; } /* ======================================================================== * Provider Management Tests * ======================================================================== */ void test_ai_client_init(void** state) { /* After init, at least one default provider must exist with a non-empty * URL. We intentionally don't pin specific names or URLs — the source * of truth lives in preferences.c (prefs_ai_get_providers) and the set * of defaults is free to grow, shrink, or be renamed without breaking * tests of unrelated functionality. */ GList* providers = ai_list_providers(); assert_non_null(providers); assert_true(g_list_length(providers) >= 1); for (GList* curr = providers; curr; curr = g_list_next(curr)) { AIProvider* p = curr->data; assert_non_null(p); assert_non_null(p->name); assert_true(strlen(p->name) > 0); assert_non_null(p->api_url); assert_true(strlen(p->api_url) > 0); } g_list_free(providers); } void test_ai_add_provider(void** state) { /* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */ AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1"); assert_non_null(provider); assert_string_equal("custom", provider->name); assert_string_equal("https://custom.api.com/v1", provider->api_url); /* Update existing provider (returns ref; caller owns it) */ AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1"); assert_non_null(updated); assert_string_equal("https://new.api.com/v1", updated->api_url); ai_provider_unref(updated); } void test_ai_remove_provider(void** state) { /* Remove default provider should fail */ assert_false(ai_remove_provider("nonexistent")); /* Add and remove custom provider */ ai_add_provider("temp", "https://temp.api.com/v1"); assert_true(ai_remove_provider("temp")); assert_null(ai_get_provider("temp")); } void test_ai_list_providers(void** state) { /* Count whatever defaults init produced — we only assert the *delta* * after ai_add_provider, not the absolute number. Lets us survive any * future change to how many built-in providers ship. */ GList* before = ai_list_providers(); gint baseline = before ? (gint)g_list_length(before) : 0; g_list_free(before); ai_add_provider("test_list_extra", "https://example.test/extra/"); GList* after = ai_list_providers(); assert_int_equal(baseline + 1, g_list_length(after)); g_list_free(after); } /* ======================================================================== * API Key Tests * ======================================================================== */ void test_ai_set_provider_key(void** state) { ai_add_provider("p_setkey", "https://example.test/setkey/"); ai_set_provider_key("p_setkey", "sk-test-key-123"); { auto_gchar gchar* key = ai_get_provider_key("p_setkey"); assert_non_null(key); assert_string_equal("sk-test-key-123", key); } /* Update key */ ai_set_provider_key("p_setkey", "sk-new-key-456"); { auto_gchar gchar* key = ai_get_provider_key("p_setkey"); assert_string_equal("sk-new-key-456", key); } /* Remove key */ ai_set_provider_key("p_setkey", NULL); { auto_gchar gchar* key = ai_get_provider_key("p_setkey"); assert_null(key); } } void test_ai_get_provider_key(void** state) { ai_add_provider("p_getkey_a", "https://example.test/getkey-a/"); ai_add_provider("p_getkey_b", "https://example.test/getkey-b/"); /* No key set initially */ { auto_gchar gchar* key = ai_get_provider_key("p_getkey_a"); assert_null(key); } /* Set and get key on one provider */ ai_set_provider_key("p_getkey_b", "pplx-abc123"); { auto_gchar gchar* key = ai_get_provider_key("p_getkey_b"); assert_non_null(key); assert_string_equal("pplx-abc123", key); } /* Other provider still has no key */ { auto_gchar gchar* key = ai_get_provider_key("p_getkey_a"); assert_null(key); } } /* ======================================================================== * Session Tests * ======================================================================== */ void test_ai_session_create(void** state) { ai_add_provider("p_sess_create", "https://example.test/sess/"); AISession* session = ai_session_create("p_sess_create", "model-a"); assert_non_null(session); assert_string_equal("p_sess_create", session->provider_name); assert_string_equal("model-a", session->model); assert_null(session->api_key); /* No key set */ ai_session_unref(session); } void test_ai_session_ref_unref(void** state) { ai_add_provider("p_sess_ref", "https://example.test/ref/"); AISession* session = ai_session_create("p_sess_ref", "model-a"); assert_non_null(session); /* Reference */ AISession* ref = ai_session_ref(session); assert_true(ref == session); /* Unreference twice */ ai_session_unref(session); ai_session_unref(ref); /* Should free here */ } void test_ai_session_add_message(void** state) { ai_add_provider("p_sess_add", "https://example.test/add/"); AISession* session = ai_session_create("p_sess_add", "model-a"); assert_non_null(session); ai_session_add_message(session, "user", "Hello"); ai_session_add_message(session, "assistant", "Hi there!"); assert_int_equal(2, g_list_length(session->history)); AIMessage* first = session->history->data; assert_string_equal("user", first->role); assert_string_equal("Hello", first->content); AIMessage* second = g_list_next(session->history)->data; assert_string_equal("assistant", second->role); assert_string_equal("Hi there!", second->content); ai_session_unref(session); } void test_ai_session_clear_history(void** state) { ai_add_provider("p_sess_clear", "https://example.test/clear/"); AISession* session = ai_session_create("p_sess_clear", "model-a"); ai_session_add_message(session, "user", "Message 1"); ai_session_add_message(session, "user", "Message 2"); ai_session_add_message(session, "assistant", "Response"); assert_int_equal(3, g_list_length(session->history)); ai_session_clear_history(session); assert_int_equal(0, g_list_length(session->history)); ai_session_unref(session); } void test_ai_session_set_model(void** state) { ai_add_provider("p_sess_setmodel", "https://example.test/setmodel/"); AISession* session = ai_session_create("p_sess_setmodel", "model-a"); assert_string_equal("model-a", session->model); ai_session_set_model(session, "model-b"); assert_string_equal("model-b", session->model); ai_session_unref(session); } /* ======================================================================== * JSON Escape Tests * ======================================================================== */ void test_ai_json_escape(void** state) { gchar* escaped = ai_json_escape("hello \"world\""); assert_string_equal("hello \\\"world\\\"", escaped); g_free(escaped); } void test_ai_json_escape_null(void** state) { gchar* escaped = ai_json_escape(NULL); assert_string_equal("", escaped); g_free(escaped); } void test_ai_json_escape_empty(void** state) { gchar* escaped = ai_json_escape(""); assert_string_equal("", escaped); g_free(escaped); } void test_ai_json_escape_special_chars(void** state) { gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote"); assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped); g_free(escaped); } void test_ai_json_escape_percent_signs(void** state) { /* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */ gchar* escaped = ai_json_escape("100% complete with %s and %d format strings"); assert_string_equal("100% complete with %s and %d format strings", escaped); g_free(escaped); } void test_ai_json_escape_backslash_quote(void** state) { /* Test escaped quote handling */ gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye"); assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped); g_free(escaped); } void test_ai_session_api_key_is_copied(void** state) { /* Verify that session owns its own copy of the API key */ ai_add_provider("p_keycopy", "https://example.test/keycopy/"); ai_set_provider_key("p_keycopy", "sk-test-key-123"); AISession* session = ai_session_create("p_keycopy", "model-a"); assert_non_null(session); assert_string_equal("sk-test-key-123", session->api_key); /* Remove the provider key - session should still have its copy */ ai_set_provider_key("p_keycopy", NULL); assert_non_null(session->api_key); assert_string_equal("sk-test-key-123", session->api_key); ai_session_unref(session); } void test_ai_add_provider_update_existing(void** state) { /* Add a provider (hash table owns ref) */ AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1"); assert_non_null(provider); assert_string_equal("https://first.api.com/v1", provider->api_url); /* Update the same provider (returns ref) */ provider = ai_add_provider("custom", "https://second.api.com/v1"); assert_non_null(provider); assert_string_equal("https://second.api.com/v1", provider->api_url); ai_provider_unref(provider); } void test_ai_add_provider_null_name_returns_null(void** state) { assert_null(ai_add_provider(NULL, "https://api.com/v1")); } void test_ai_add_provider_null_url_returns_null(void** state) { assert_null(ai_add_provider("test", NULL)); } void test_ai_session_create_null_provider_returns_null(void** state) { assert_null(ai_session_create("nonexistent", "gpt-4")); } void test_ai_session_create_null_model_returns_null(void** state) { ai_add_provider("p_create_nullmodel", "https://example.test/nm/"); assert_null(ai_session_create("p_create_nullmodel", NULL)); } void test_ai_session_api_key_null_when_no_key_set(void** state) { /* Provider without a stored key — session->api_key must be NULL. */ ai_add_provider("p_nokey", "https://example.test/nokey/"); AISession* session = ai_session_create("p_nokey", "model-a"); assert_non_null(session); assert_null(session->api_key); ai_session_unref(session); } /* ======================================================================== * Provider Autocomplete Tests * ======================================================================== */ void test_ai_providers_find_forward(void** state) { /* Distinctive prefix that won't collide with any current or future * default. Self-add provider so the test doesn't depend on which * built-ins ship. */ ai_add_provider("zfind_one", "https://example.test/zf1/"); auto_gchar gchar* result = ai_providers_find("zfind", FALSE, NULL); assert_non_null(result); assert_string_equal("zfind_one", result); } void test_ai_providers_find_forward_custom(void** state) { ai_add_provider("zcustom", "https://example.test/zcustom/"); auto_gchar gchar* result = ai_providers_find("zcustom", FALSE, NULL); assert_non_null(result); assert_string_equal("zcustom", result); } void test_ai_providers_find_forward_no_match(void** state) { /* Prefix designed to match no provider — neither defaults nor any * we ourselves added. */ auto_gchar gchar* result = ai_providers_find("__nope__", FALSE, NULL); assert_null(result); } void test_ai_providers_find_forward_partial_match(void** state) { ai_add_provider("zpartial_xyz", "https://example.test/zp/"); auto_gchar gchar* result = ai_providers_find("zpart", FALSE, NULL); assert_non_null(result); assert_string_equal("zpartial_xyz", result); } void test_ai_providers_find_next(void** state) { /* Two consecutive calls with the same prefix and only one matching * provider must return that same provider both times. */ ai_add_provider("znext_one", "https://example.test/zn1/"); auto_gchar gchar* result1 = ai_providers_find("znext", FALSE, NULL); assert_non_null(result1); assert_string_equal("znext_one", result1); auto_gchar gchar* result2 = ai_providers_find("znext", FALSE, NULL); assert_non_null(result2); assert_string_equal("znext_one", result2); } void test_ai_providers_find_previous(void** state) { /* With only one match the previous-direction call still returns it. */ ai_add_provider("zprev_one", "https://example.test/zp1/"); auto_gchar gchar* result1 = ai_providers_find("zprev", FALSE, NULL); assert_non_null(result1); assert_string_equal("zprev_one", result1); auto_gchar gchar* result2 = ai_providers_find("zprev", TRUE, NULL); assert_non_null(result2); assert_string_equal("zprev_one", result2); } void test_ai_providers_find_empty_search_str(void** state) { auto_gchar gchar* result = ai_providers_find("", FALSE, NULL); assert_non_null(result); } /* * ai_providers_find(NULL, ...) currently SIGSEGVs in libglib's * __strlen_avx2: autocomplete_complete() stores g_strdup(NULL) == NULL * in ac->search_str, and the downstream _search() forwards that NULL into * g_str_to_ascii(NULL, NULL), which is undefined. * * The crash isn't reachable from production today (cmd_ac.c's * _autocomplete_param_common always passes a non-NULL malloc'd substring), * but the public ai_providers_find() signature accepts NULL and the test * documents that it should not crash. The test is intentionally not * registered in unittests.c while the NULL path remains a SIGSEGV — see * AI_AUTOCOMPLETE_POSSIBLE_ISSUES.md, potential issue 1. */ void test_ai_providers_find_null_search_str(void** state) { auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL); assert_non_null(result); } void test_ai_providers_find_case_insensitive(void** state) { /* Matching is case-insensitive (via g_ascii_strdown). Use a self-added * provider with a distinctive lowercased name. */ ai_add_provider("zcase_marker", "https://example.test/zcm/"); auto_gchar gchar* r1 = ai_providers_find("ZCASE_MARKER", FALSE, NULL); assert_non_null(r1); assert_string_equal("zcase_marker", r1); auto_gchar gchar* r2 = ai_providers_find("Zcase_Marker", FALSE, NULL); assert_non_null(r2); assert_string_equal("zcase_marker", r2); auto_gchar gchar* r3 = ai_providers_find("zcase_marker", FALSE, NULL); assert_non_null(r3); assert_string_equal("zcase_marker", r3); } /* ======================================================================== * AI Autocomplete Integration Tests * ======================================================================== */ void test_ai_start_provider_autocomplete_only_on_exact(void** state) { /* Empty search should return *some* provider — exact name depends on * which defaults shipped. We only care that the call doesn't crash and * yields a non-NULL result. */ auto_gchar gchar* result = ai_providers_find("", FALSE, NULL); assert_non_null(result); } void test_ai_providers_find_cycling(void** state) { /* Add two distinct providers; empty-prefix cycling should walk through * both (defaults may also be present, but the assertion only cares that * two consecutive calls return different providers). */ ai_add_provider("zcycle_a", "https://example.test/zca/"); ai_add_provider("zcycle_b", "https://example.test/zcb/"); auto_gchar gchar* result1 = ai_providers_find("", FALSE, NULL); assert_non_null(result1); auto_gchar gchar* result2 = ai_providers_find("", FALSE, NULL); assert_non_null(result2); assert_string_not_equal(result1, result2); } /* ======================================================================== * Provider Default Model and Settings Tests * ======================================================================== */ void test_ai_set_provider_default_model(void** state) { ai_add_provider("p_dm_set_a", "https://example.test/dma/"); ai_add_provider("p_dm_set_b", "https://example.test/dmb/"); ai_set_provider_default_model("p_dm_set_a", "model-1"); const gchar* model = ai_get_provider_default_model("p_dm_set_a"); assert_non_null(model); assert_string_equal("model-1", model); /* Update default model */ ai_set_provider_default_model("p_dm_set_a", "model-1-update"); model = ai_get_provider_default_model("p_dm_set_a"); assert_non_null(model); assert_string_equal("model-1-update", model); /* Independent default model on a different provider */ ai_set_provider_default_model("p_dm_set_b", "model-2"); model = ai_get_provider_default_model("p_dm_set_b"); assert_non_null(model); assert_string_equal("model-2", model); } void test_ai_get_provider_default_model(void** state) { /* NULL provider returns NULL */ assert_null(ai_get_provider_default_model(NULL)); /* Non-existent provider returns NULL */ assert_null(ai_get_provider_default_model("nonexistent")); ai_add_provider("p_dm_get", "https://example.test/dmg/"); /* After setting, it returns the model */ ai_set_provider_default_model("p_dm_get", "test-model"); assert_string_equal("test-model", ai_get_provider_default_model("p_dm_get")); /* NULL model argument is ignored (no change) */ ai_set_provider_default_model("p_dm_get", NULL); assert_string_equal("test-model", ai_get_provider_default_model("p_dm_get")); } void test_ai_set_provider_setting(void** state) { ai_add_provider("p_set_settings", "https://example.test/sset/"); /* NULL provider should be ignored */ ai_set_provider_setting(NULL, "tools", "enabled"); /* NULL setting should be ignored */ ai_set_provider_setting("p_set_settings", NULL, "enabled"); /* Non-existent provider should log warning but not crash */ ai_set_provider_setting("nonexistent", "tools", "enabled"); /* Set settings for our provider */ ai_set_provider_setting("p_set_settings", "tools", "enabled"); ai_set_provider_setting("p_set_settings", "search", "disabled"); /* Verify settings were set */ gchar* tools = ai_get_provider_setting("p_set_settings", "tools"); assert_non_null(tools); assert_string_equal("enabled", tools); g_free(tools); gchar* search = ai_get_provider_setting("p_set_settings", "search"); assert_non_null(search); assert_string_equal("disabled", search); g_free(search); /* NULL value removes the setting */ ai_set_provider_setting("p_set_settings", "tools", NULL); tools = ai_get_provider_setting("p_set_settings", "tools"); assert_null(tools); /* Setting that was never set returns NULL */ assert_null(ai_get_provider_setting("p_set_settings", "nonexistent_setting")); } void test_ai_set_provider_setting_reserved_key(void** state) { ai_add_provider("p_reserved_keys", "https://example.test/rk/"); /* Reserved payload keys are rejected (would duplicate fixed fields) */ assert_false(ai_set_provider_setting("p_reserved_keys", "model", "x")); assert_false(ai_set_provider_setting("p_reserved_keys", "messages", "[]")); assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true")); assert_null(ai_get_provider_setting("p_reserved_keys", "model")); /* Ordinary keys still work */ assert_true(ai_set_provider_setting("p_reserved_keys", "temperature", "0.7")); auto_gchar gchar* temp = ai_get_provider_setting("p_reserved_keys", "temperature"); assert_non_null(temp); assert_string_equal("0.7", temp); } void test_ai_get_provider_setting(void** state) { ai_add_provider("p_get_settings_a", "https://example.test/gseta/"); ai_add_provider("p_get_settings_b", "https://example.test/gsetb/"); /* NULL provider returns NULL */ assert_null(ai_get_provider_setting(NULL, "tools")); /* NULL setting returns NULL */ assert_null(ai_get_provider_setting("p_get_settings_a", NULL)); /* Non-existent provider returns NULL */ assert_null(ai_get_provider_setting("nonexistent", "tools")); /* Provider with no settings configured returns NULL */ assert_null(ai_get_provider_setting("p_get_settings_b", "tools")); /* Set and get setting */ ai_set_provider_setting("p_get_settings_a", "temperature", "0.7"); gchar* temp = ai_get_provider_setting("p_get_settings_a", "temperature"); assert_non_null(temp); assert_string_equal("0.7", temp); g_free(temp); /* Setting value is a copy (caller must free) */ ai_set_provider_setting("p_get_settings_a", "max_tokens", "2048"); temp = ai_get_provider_setting("p_get_settings_a", "max_tokens"); assert_non_null(temp); assert_string_equal("2048", temp); g_free(temp); } /* ======================================================================== * Model Caching Tests * ======================================================================== */ void test_ai_models_are_fresh_initial(void** state) { /* NULL provider returns FALSE */ assert_false(ai_models_are_fresh(NULL)); /* Non-existent provider returns FALSE */ assert_false(ai_models_are_fresh("nonexistent")); /* A freshly-added provider with no cached models should return FALSE * (or at least not crash). */ ai_add_provider("p_fresh", "https://example.test/fresh/"); gboolean fresh = ai_models_are_fresh("p_fresh"); assert_true(fresh == TRUE || fresh == FALSE); } /* ======================================================================== * Model Parsing Tests * ======================================================================== */ void test_ai_parse_models_openai_format(void** state) { /* Test parsing OpenAI-compatible API response format */ /* Format: {"object":"list","data":[{"id":"model1",...},...]} */ ai_client_init(); AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); /* JSON response from Perplexity/OpenAI API */ const gchar* json = "{\"object\":\"list\"," "\"data\":[" "{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}" "]}"; ai_parse_models_from_json(provider, json); /* Verify models were parsed correctly */ assert_int_equal(4, g_list_length(provider->models)); /* Check specific models */ GList* models = provider->models; assert_string_equal("anthropic/claude-haiku-4-5", models->data); models = g_list_next(models); assert_string_equal("anthropic/claude-opus-4-5", models->data); models = g_list_next(models); assert_string_equal("openai/gpt-5", models->data); models = g_list_next(models); assert_string_equal("google/gemini-3-flash-preview", models->data); ai_client_shutdown(); } void test_ai_parse_models_perplexity_format(void** state) { /* Test parsing actual Perplexity API response with all models from curl test */ ai_client_init(); AIProvider* provider = ai_add_provider("p_models_perplex_fmt", "https://example.test/pmpf/"); const gchar* json = "{\"object\":\"list\"," "\"data\":[" "{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"anthropic/claude-opus-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"anthropic/claude-opus-4-7\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"anthropic/claude-sonnet-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"anthropic/claude-sonnet-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," "{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}," "{\"id\":\"google/gemini-3.1-pro-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}," "{\"id\":\"nvidia/nemotron-3-super-120b-a12b\",\"object\":\"model\",\"created\":0,\"owned_by\":\"nvidia\"}," "{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5.1\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5.2\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5.4\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5.4-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5.4-nano\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"openai/gpt-5.5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," "{\"id\":\"perplexity/sonar\",\"object\":\"model\",\"created\":0,\"owned_by\":\"perplexity\"}," "{\"id\":\"xai/grok-4-1-fast-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," "{\"id\":\"xai/grok-4.20-multi-agent\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," "{\"id\":\"xai/grok-4.20-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," "{\"id\":\"xai/grok-4.20-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," "{\"id\":\"xai/grok-4.3\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}" "]}"; ai_parse_models_from_json(provider, json); /* Verify all 23 models were parsed correctly */ assert_int_equal(23, g_list_length(provider->models)); /* Verify NO field names or owned_by values are in the model list */ /* The bug was that "id", "object", "model", "created", "owned_by" and provider names * like "anthropic", "google", "openai", "nvidia", "perplexity", "xai" were extracted */ GList* models = provider->models; while (models) { const gchar* model = (const gchar*)models->data; /* These should never be model IDs */ assert_false(g_strcmp0(model, "id") == 0); assert_false(g_strcmp0(model, "object") == 0); assert_false(g_strcmp0(model, "model") == 0); assert_false(g_strcmp0(model, "created") == 0); assert_false(g_strcmp0(model, "owned_by") == 0); assert_false(g_strcmp0(model, "list") == 0); /* These are owned_by values, not model IDs */ assert_false(g_strcmp0(model, "anthropic") == 0); assert_false(g_strcmp0(model, "google") == 0); assert_false(g_strcmp0(model, "openai") == 0); assert_false(g_strcmp0(model, "nvidia") == 0); assert_false(g_strcmp0(model, "perplexity") == 0); assert_false(g_strcmp0(model, "xai") == 0); models = g_list_next(models); } /* Verify first and last model IDs */ models = provider->models; assert_string_equal("anthropic/claude-haiku-4-5", models->data); models = g_list_last(models); assert_string_equal("xai/grok-4.3", models->data); ai_client_shutdown(); } void test_ai_parse_models_array_format(void** state) { /* Test parsing simple array format: ["model1", "model2", ...] */ ai_client_init(); AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); const gchar* json = "[\"model1\", \"model2\", \"model3\"]"; ai_parse_models_from_json(provider, json); assert_int_equal(3, g_list_length(provider->models)); GList* models = provider->models; assert_string_equal("model1", models->data); models = g_list_next(models); assert_string_equal("model2", models->data); models = g_list_next(models); assert_string_equal("model3", models->data); ai_client_shutdown(); } void test_ai_parse_models_empty_json(void** state) { /* Test parsing empty/invalid JSON */ ai_client_init(); AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); ai_parse_models_from_json(provider, ""); assert_null(provider->models); assert_int_equal(0, g_list_length(provider->models)); ai_parse_models_from_json(provider, "{\"invalid\": true}"); assert_null(provider->models); assert_int_equal(0, g_list_length(provider->models)); ai_client_shutdown(); } void test_ai_parse_models_null_handling(void** state) { /* Test NULL handling */ ai_client_init(); AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); /* NULL json should not crash */ ai_parse_models_from_json(provider, NULL); assert_null(provider->models); ai_client_shutdown(); } void test_ai_parse_models_escaped_quotes(void** state) { /* Test parsing model IDs with escaped quotes */ ai_client_init(); AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); const gchar* json = "{\"object\":\"list\"," "\"data\":[" "{\"id\":\"test/model\\\"with\\\"quotes\",\"object\":\"model\",\"created\":0,\"owned_by\":\"test\"}" "]}"; ai_parse_models_from_json(provider, json); assert_int_equal(1, g_list_length(provider->models)); /* Model ID should have the escaped quotes handled */ GList* models = provider->models; assert_non_null(models); ai_client_shutdown(); } void test_ai_parse_models_with_whitespace(void** state) { /* Test parsing JSON with whitespace after colons */ ai_client_init(); AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); /* JSON with spaces after colons (common in formatted JSON) */ const gchar* json = "{ \"object\": \"list\", " "\"data\": [ " "{ \"id\": \"model-with-spaces\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"test\" }" "]}"; ai_parse_models_from_json(provider, json); assert_int_equal(1, g_list_length(provider->models)); GList* models = provider->models; assert_string_equal("model-with-spaces", models->data); ai_client_shutdown(); } /* ======================================================================== * Setup combining prefs + ai_client for round-trip persistence tests * ======================================================================== */ int ai_client_setup_with_prefs(void** state) { if (load_preferences(state) != 0) { return 1; } ai_client_init(); return 0; } int ai_client_teardown_with_prefs(void** state) { ai_client_shutdown(); close_preferences(state); return 0; } /* ======================================================================== * Chat response parser tests (ai_parse_response) * ======================================================================== */ void test_ai_parse_response_openai_content(void** state) { const gchar* json = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hello, world!\"}}]}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("Hello, world!", out); } void test_ai_parse_response_legacy_text_format_unsupported(void** state) { /* Legacy Perplexity /v1/agent format: "content" is an array, not a string. * Dropped with the chat-completions alignment, so parsing yields NULL. */ const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}"; assert_null(ai_parse_response(json)); } void test_ai_parse_response_content_preferred_over_text(void** state) { /* Chat-completions "content" is extracted; a stray "text" field is ignored. */ const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("from content field", out); } void test_ai_parse_response_escaped_quote(void** state) { const gchar* json = "{\"content\":\"He said \\\"hello\\\" today\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("He said \"hello\" today", out); } void test_ai_parse_response_newline_escape(void** state) { const gchar* json = "{\"content\":\"line one\\nline two\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("line one\nline two", out); } void test_ai_parse_response_empty_content(void** state) { const gchar* json = "{\"content\":\"\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("", out); } void test_ai_parse_response_missing_field(void** state) { const gchar* json = "{\"foo\":\"bar\"}"; gchar* out = ai_parse_response(json); assert_null(out); } void test_ai_parse_response_null_input(void** state) { assert_null(ai_parse_response(NULL)); } void test_ai_parse_response_empty_input(void** state) { assert_null(ai_parse_response("")); } void test_ai_parse_response_percent_signs_safe(void** state) { /* Format-string safety: %s, %d in content must not be interpreted later. */ const gchar* json = "{\"content\":\"100% done with %s and %d markers\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("100% done with %s and %d markers", out); } void test_ai_parse_response_braces_in_content(void** state) { /* Content containing JSON-looking braces should not confuse the parser. */ const gchar* json = "{\"content\":\"use {key: value} syntax\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("use {key: value} syntax", out); } void test_ai_parse_response_multiline_content(void** state) { const gchar* json = "{\"content\":\"para 1\\n\\npara 2\\nlast\"}"; auto_gchar gchar* out = ai_parse_response(json); assert_non_null(out); assert_string_equal("para 1\n\npara 2\nlast", out); } /* ======================================================================== * Error response parser tests (ai_parse_error_message) * ======================================================================== */ void test_ai_parse_error_standard_envelope(void** state) { const gchar* json = "{\"error\":{\"message\":\"Invalid API key provided\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}"; auto_gchar gchar* out = ai_parse_error_message(json); assert_non_null(out); assert_string_equal("Invalid API key provided", out); } void test_ai_parse_error_with_escapes(void** state) { const gchar* json = "{\"error\":{\"message\":\"Field \\\"model\\\" is required\"}}"; auto_gchar gchar* out = ai_parse_error_message(json); assert_non_null(out); assert_string_equal("Field \"model\" is required", out); } void test_ai_parse_error_no_error_field(void** state) { const gchar* json = "{\"message\":\"this is not in an error envelope\"}"; gchar* out = ai_parse_error_message(json); assert_null(out); } void test_ai_parse_error_no_message_field(void** state) { const gchar* json = "{\"error\":{\"type\":\"some_error\",\"code\":42}}"; gchar* out = ai_parse_error_message(json); assert_null(out); } void test_ai_parse_error_null_input(void** state) { assert_null(ai_parse_error_message(NULL)); } void test_ai_parse_error_empty_input(void** state) { assert_null(ai_parse_error_message("")); } void test_ai_parse_error_tab_escape(void** state) { const gchar* json = "{\"error\":{\"message\":\"col1\\tcol2\"}}"; auto_gchar gchar* out = ai_parse_error_message(json); assert_non_null(out); assert_string_equal("col1\tcol2", out); } void test_ai_parse_error_backslash_escape(void** state) { const gchar* json = "{\"error\":{\"message\":\"path C:\\\\Users\\\\x\"}}"; auto_gchar gchar* out = ai_parse_error_message(json); assert_non_null(out); assert_string_equal("path C:\\Users\\x", out); } /* ======================================================================== * Extended JSON escape tests * ======================================================================== */ void test_ai_json_escape_backspace(void** state) { auto_gchar gchar* out = ai_json_escape("a\bb"); assert_string_equal("a\\bb", out); } void test_ai_json_escape_formfeed(void** state) { auto_gchar gchar* out = ai_json_escape("a\fb"); assert_string_equal("a\\fb", out); } void test_ai_json_escape_carriage_return(void** state) { auto_gchar gchar* out = ai_json_escape("a\rb"); assert_string_equal("a\\rb", out); } void test_ai_json_escape_all_specials_combined(void** state) { auto_gchar gchar* out = ai_json_escape("\"\\/\b\f\n\r\t"); /* `/` is not escaped by the encoder (valid JSON either way). */ assert_string_equal("\\\"\\\\/\\b\\f\\n\\r\\t", out); } void test_ai_json_escape_passes_utf8_through(void** state) { /* UTF-8 bytes are valid in JSON strings and should pass through unchanged. */ auto_gchar gchar* out = ai_json_escape("Привет 🦀"); assert_string_equal("Привет 🦀", out); } /* ======================================================================== * Autocomplete cycling with many providers * ======================================================================== */ void test_ai_providers_find_cycles_through_many(void** state) { /* Add several providers sharing a prefix; cycling should visit each. */ ai_add_provider("alpha", "https://a.example/"); ai_add_provider("beta", "https://b.example/"); ai_add_provider("alphabet", "https://ab.example/"); ai_add_provider("alpine", "https://al.example/"); /* Three providers share prefix "alp": alpha, alphabet, alpine. */ GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); for (int i = 0; i < 3; i++) { auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL); assert_non_null(match); /* Each call should return a result that starts with "alp". */ assert_true(g_str_has_prefix(match, "alp")); g_hash_table_add(seen, g_strdup(match)); } /* All three should have been seen (deterministic cycling). */ assert_int_equal(3, g_hash_table_size(seen)); g_hash_table_destroy(seen); } void test_ai_providers_find_previous_walks_backwards(void** state) { ai_add_provider("alpha", "https://a/"); ai_add_provider("alphabet", "https://ab/"); ai_add_provider("alpine", "https://al/"); /* Forward then backward should yield matches with the shared prefix. */ auto_gchar gchar* forward = ai_providers_find("alp", FALSE, NULL); auto_gchar gchar* back = ai_providers_find("alp", TRUE, NULL); assert_non_null(forward); assert_non_null(back); assert_true(g_str_has_prefix(forward, "alp")); assert_true(g_str_has_prefix(back, "alp")); } void test_ai_providers_find_wraps_around(void** state) { ai_add_provider("alpha", "https://a/"); ai_add_provider("alphabet", "https://ab/"); /* Two matches; four forward calls should produce a repeat. */ auto_gchar gchar* r1 = ai_providers_find("alp", FALSE, NULL); auto_gchar gchar* r2 = ai_providers_find("alp", FALSE, NULL); auto_gchar gchar* r3 = ai_providers_find("alp", FALSE, NULL); auto_gchar gchar* r4 = ai_providers_find("alp", FALSE, NULL); assert_non_null(r1); assert_non_null(r2); assert_non_null(r3); assert_non_null(r4); /* The cycle of 2 must repeat: r1 == r3 and r2 == r4 (in some order). */ assert_int_equal(0, g_strcmp0(r1, r3)); assert_int_equal(0, g_strcmp0(r2, r4)); assert_int_not_equal(0, g_strcmp0(r1, r2)); } /* ======================================================================== * Session edge cases * ======================================================================== */ void test_ai_session_add_message_null_session_no_crash(void** state) { /* Should silently no-op rather than dereference NULL. */ ai_session_add_message(NULL, "user", "hello"); } void test_ai_session_add_message_null_role_no_crash(void** state) { ai_add_provider("p_addmsg_nullrole", "https://example.test/anr/"); AISession* s = ai_session_create("p_addmsg_nullrole", "model-a"); assert_non_null(s); ai_session_add_message(s, NULL, "content"); assert_int_equal(0, g_list_length(s->history)); ai_session_unref(s); } void test_ai_session_add_message_null_content_no_crash(void** state) { ai_add_provider("p_addmsg_nullcontent", "https://example.test/anc/"); AISession* s = ai_session_create("p_addmsg_nullcontent", "model-a"); assert_non_null(s); ai_session_add_message(s, "user", NULL); assert_int_equal(0, g_list_length(s->history)); ai_session_unref(s); } void test_ai_session_history_preserves_large_order(void** state) { ai_add_provider("p_history_order", "https://example.test/ho/"); AISession* s = ai_session_create("p_history_order", "model-a"); assert_non_null(s); for (int i = 0; i < 100; i++) { auto_gchar gchar* content = g_strdup_printf("msg-%d", i); ai_session_add_message(s, (i % 2 == 0) ? "user" : "assistant", content); } assert_int_equal(100, g_list_length(s->history)); GList* curr = s->history; for (int i = 0; i < 100 && curr; i++, curr = g_list_next(curr)) { AIMessage* msg = curr->data; auto_gchar gchar* expected = g_strdup_printf("msg-%d", i); assert_string_equal(expected, msg->content); assert_string_equal((i % 2 == 0) ? "user" : "assistant", msg->role); } ai_session_unref(s); } void test_ai_session_clear_empty_history(void** state) { ai_add_provider("p_clear_empty", "https://example.test/ce/"); AISession* s = ai_session_create("p_clear_empty", "model-a"); assert_non_null(s); ai_session_clear_history(s); assert_int_equal(0, g_list_length(s->history)); ai_session_unref(s); } void test_ai_session_set_model_null_keeps_old(void** state) { ai_add_provider("p_setmodel_null", "https://example.test/smn/"); AISession* s = ai_session_create("p_setmodel_null", "model-original"); assert_non_null(s); ai_session_set_model(s, NULL); assert_string_equal("model-original", s->model); ai_session_unref(s); } void test_ai_session_unref_null_no_crash(void** state) { ai_session_unref(NULL); } void test_ai_session_ref_null_returns_null(void** state) { assert_null(ai_session_ref(NULL)); } /* ======================================================================== * Provider edge cases * ======================================================================== */ void test_ai_get_provider_null_returns_null(void** state) { assert_null(ai_get_provider(NULL)); } void test_ai_remove_provider_null_returns_false(void** state) { assert_false(ai_remove_provider(NULL)); } void test_ai_remove_provider_twice_second_fails(void** state) { ai_add_provider("once", "https://once/"); assert_true(ai_remove_provider("once")); assert_false(ai_remove_provider("once")); } void test_ai_provider_survives_via_session_after_removal(void** state) { ai_add_provider("temp_prov", "https://temp/"); ai_set_provider_key("temp_prov", "test-key"); AISession* s = ai_session_create("temp_prov", "model-x"); assert_non_null(s); /* Remove from registry while session holds a ref. */ assert_true(ai_remove_provider("temp_prov")); assert_null(ai_get_provider("temp_prov")); /* Session's provider pointer must still be valid. */ assert_non_null(s->provider); assert_string_equal("temp_prov", s->provider->name); assert_string_equal("https://temp/", s->provider->api_url); ai_session_unref(s); } /* ======================================================================== * Settings edge cases * ======================================================================== */ void test_ai_settings_multiple_keys_independent(void** state) { ai_add_provider("p_settings_multi", "https://example.test/sm/"); ai_set_provider_setting("p_settings_multi", "tools", "enabled"); ai_set_provider_setting("p_settings_multi", "search", "disabled"); auto_gchar gchar* tools = ai_get_provider_setting("p_settings_multi", "tools"); auto_gchar gchar* search = ai_get_provider_setting("p_settings_multi", "search"); assert_string_equal("enabled", tools); assert_string_equal("disabled", search); } void test_ai_settings_get_missing_returns_null(void** state) { ai_add_provider("p_settings_missing", "https://example.test/smi/"); gchar* nope = ai_get_provider_setting("p_settings_missing", "not_set"); assert_null(nope); } void test_ai_settings_isolated_between_providers(void** state) { /* Distinct URLs to make accidental cross-talk visible if it ever happens. */ ai_add_provider("p_settings_iso_a", "https://example.test/sia/"); ai_add_provider("p_settings_iso_b", "https://example.test/sib/"); ai_set_provider_setting("p_settings_iso_a", "tools", "yes"); ai_set_provider_setting("p_settings_iso_b", "tools", "no"); auto_gchar gchar* a = ai_get_provider_setting("p_settings_iso_a", "tools"); auto_gchar gchar* b = ai_get_provider_setting("p_settings_iso_b", "tools"); assert_string_equal("yes", a); assert_string_equal("no", b); } /* ======================================================================== * Model parsing edge cases * ======================================================================== */ void test_ai_parse_models_data_not_array(void** state) { AIProvider* p = ai_add_provider("p1", "https://p/"); assert_non_null(p); /* "data" present but not an array — parser should bail without crashing. */ const gchar* json = "{\"data\":\"not-an-array\"}"; ai_parse_models_from_json(p, json); assert_int_equal(0, g_list_length(p->models)); } void test_ai_parse_models_empty_data_array(void** state) { AIProvider* p = ai_add_provider("p1", "https://p/"); assert_non_null(p); const gchar* json = "{\"object\":\"list\",\"data\":[]}"; ai_parse_models_from_json(p, json); assert_int_equal(0, g_list_length(p->models)); } void test_ai_parse_models_id_outside_data_ignored(void** state) { AIProvider* p = ai_add_provider("p1", "https://p/"); assert_non_null(p); /* "id" outside a data-array object must not be picked up. */ const gchar* json = "{\"request_id\":\"req-123\",\"data\":[]}"; ai_parse_models_from_json(p, json); assert_int_equal(0, g_list_length(p->models)); } void test_ai_parse_models_multiple_models(void** state) { AIProvider* p = ai_add_provider("p1", "https://p/"); assert_non_null(p); const gchar* json = "{\"object\":\"list\",\"data\":[" "{\"id\":\"m-1\",\"object\":\"model\"}," "{\"id\":\"m-2\",\"object\":\"model\"}," "{\"id\":\"m-3\",\"object\":\"model\"}" "]}"; ai_parse_models_from_json(p, json); assert_int_equal(3, g_list_length(p->models)); } /* ======================================================================== * Prefs round-trip tests * * These use ai_client_setup_with_prefs which initializes prefs first. * ai_set_provider_key writes through to prefs_ai_set_token which then * calls _save_prefs(); a subsequent shutdown+init should reload the key. * ======================================================================== */ void test_ai_prefs_round_trip_api_key(void** state) { ai_add_provider("p_persist_one", "https://example.test/p1/"); ai_set_provider_key("p_persist_one", "sk-persisted-123"); { auto_gchar gchar* before = ai_get_provider_key("p_persist_one"); assert_non_null(before); assert_string_equal("sk-persisted-123", before); } /* Cycle the AI client; prefs stay loaded across this. */ ai_client_shutdown(); ai_client_init(); auto_gchar gchar* after = ai_get_provider_key("p_persist_one"); assert_non_null(after); assert_string_equal("sk-persisted-123", after); } void test_ai_prefs_round_trip_remove_key(void** state) { ai_add_provider("p_persist_remove", "https://example.test/pr/"); ai_set_provider_key("p_persist_remove", "sk-to-be-removed"); ai_set_provider_key("p_persist_remove", NULL); ai_client_shutdown(); ai_client_init(); auto_gchar gchar* after = ai_get_provider_key("p_persist_remove"); assert_null(after); } void test_ai_prefs_multiple_providers_persist(void** state) { /* Two providers with distinct URLs so a future bug that swaps URLs * between providers would be visible (we don't assert URLs here, but * keeping them distinct keeps the test artifact useful). */ ai_add_provider("p_persist_multi_a", "https://example.test/pma/"); ai_add_provider("p_persist_multi_b", "https://example.test/pmb/"); ai_set_provider_key("p_persist_multi_a", "sk-multi-a"); ai_set_provider_key("p_persist_multi_b", "sk-multi-b"); ai_client_shutdown(); ai_client_init(); auto_gchar gchar* k1 = ai_get_provider_key("p_persist_multi_a"); auto_gchar gchar* k2 = ai_get_provider_key("p_persist_multi_b"); assert_non_null(k1); assert_non_null(k2); assert_string_equal("sk-multi-a", k1); assert_string_equal("sk-multi-b", k2); } /* ======================================================================== * Autocomplete deeper coverage — providers * * Model autocomplete (ai_models_find) tests were removed when that public * symbol was retired in favour of a static ai_models_ac inside cmd_ac.c. * The cycling cases below now exercise only the providers-side AC. * ======================================================================== */ void test_ai_providers_find_after_remove_skips_removed(void** state) { ai_add_provider("zalpha", "https://z/"); auto_gchar gchar* before = ai_providers_find("zalpha", FALSE, NULL); assert_non_null(before); assert_string_equal("zalpha", before); assert_true(ai_remove_provider("zalpha")); /* After removal, completion must not return the removed provider. */ auto_gchar gchar* after = ai_providers_find("zalpha", FALSE, NULL); assert_null(after); } /* ======================================================================== * Reset hook + persistence (prefs-backed providers and model cache) * ======================================================================== */ void test_ai_providers_reset_ac_restarts_cycle(void** state) { /* After ai_providers_reset_ac() the cycling cursor must point back to * the start of the list — the next ai_providers_find() call must * return the same entry as the very first call before any cycling. */ ai_add_provider("zreset_a", "https://example.test/zra/"); ai_add_provider("zreset_b", "https://example.test/zrb/"); auto_gchar gchar* first = ai_providers_find("zreset", FALSE, NULL); auto_gchar gchar* second = ai_providers_find("zreset", FALSE, NULL); assert_non_null(first); assert_non_null(second); assert_string_not_equal(first, second); ai_providers_reset_ac(); auto_gchar gchar* after_reset = ai_providers_find("zreset", FALSE, NULL); assert_non_null(after_reset); assert_string_equal(first, after_reset); } void test_ai_add_provider_persisted_across_init(void** state) { /* A provider added through the public API must survive an * ai_client_shutdown + ai_client_init cycle with the same URL. */ ai_add_provider("p_persist_provider", "https://example.test/persist-prov/"); assert_non_null(ai_get_provider("p_persist_provider")); ai_client_shutdown(); ai_client_init(); AIProvider* after = ai_get_provider("p_persist_provider"); assert_non_null(after); assert_string_equal("https://example.test/persist-prov/", after->api_url); } void test_ai_remove_provider_persisted_across_init(void** state) { /* A provider removed by the user must not be reintroduced by the * default-bootstrap path on the next init. */ ai_add_provider("p_persist_remove_prov", "https://example.test/remprov/"); assert_non_null(ai_get_provider("p_persist_remove_prov")); assert_true(ai_remove_provider("p_persist_remove_prov")); assert_null(ai_get_provider("p_persist_remove_prov")); ai_client_shutdown(); ai_client_init(); assert_null(ai_get_provider("p_persist_remove_prov")); } void test_ai_models_persisted_across_init(void** state) { /* Models cached on a provider must be reloaded from prefs on the next * ai_client_init — the count and order should match. * * Avoid a provider name ending in "_models": prefs_ai_list_providers() * filters out keys ending with "_models_url" to skip the model-list * shadow keys, which would also swallow a real provider whose name * happens to end with "_models". */ AIProvider* p = ai_add_provider("p_modelpersist", "https://example.test/pmodels/"); assert_non_null(p); const gchar* json = "{\"object\":\"list\",\"data\":[" "{\"id\":\"model-alpha\",\"object\":\"model\"}," "{\"id\":\"model-beta\",\"object\":\"model\"}" "]}"; ai_parse_models_from_json(p, json); assert_int_equal(2, g_list_length(p->models)); ai_client_shutdown(); ai_client_init(); AIProvider* after = ai_get_provider("p_modelpersist"); assert_non_null(after); assert_int_equal(2, g_list_length(after->models)); }