Files
cproof/tests/unittests/test_ai_client.c
Jabber Developer 9e5dfb14f8
All checks were successful
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 29s
CI Code / Code Coverage (push) Successful in 2m44s
CI Code / Linux (debian) (push) Successful in 4m46s
CI Code / Linux (ubuntu) (push) Successful in 4m59s
CI Code / Linux (arch) (push) Successful in 5m56s
feat(ai): add AI client with multi-provider chat support
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom endpoints) to provide
AI-assisted chat within CProof. Users can start sessions with /ai start,
send prompts, receive responses in a dedicated AI window, switch between
providers and models, and manage API keys — all with tab-completion.

Providers are configured via /ai set commands with per-provider API keys,
endpoints, default models, and custom settings. Two default providers
(openai, perplexity) are seeded on first use. Provider state persists in
[ai/<name>] sections of the preferences keyfile with automatic migration
from the previous flat-key format.

The /ai command integrates into the existing command system with 8
subcommands covering provider management, session lifecycle, model
fetching, and conversation clearing. Autocomplete uses the standard
flat prefix-matching chain for reliable tab-completion at every nesting
level. A new ProfAiWin window type is added to the window system.

Architecture:

Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls
Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call
Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s)
Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training
Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM
JSON parsing uses unified helpers for both chat responses and error
envelopes with consistent escape decoding. The response parser tries
Perplexity /v1/responses "text" field first, then falls back to OpenAI
"content". Error parsing extracts provider error.message from the
standard envelope format. Model parsing handles multiple API response
formats (OpenAI list, Perplexity, array) including edge cases.

Tests include 470+ lines of unit tests covering provider management,
session lifecycle, JSON parsing (multiple formats), autocomplete cycling,
and error handling, plus functional tests for /ai command dispatch.
A stub_ai.c module isolates unit tests from UI dependencies.
2026-05-15 02:21:54 +00:00

1609 lines
53 KiB
C

#include "glib.h"
#include "prof_cmocka.h"
#include "common.h"
#include "ai/ai_client.h"
#include "config/preferences.h"
#include "helpers.h"
#include <stdlib.h>
#include <string.h>
/* ========================================================================
* 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_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_perplexity_text(void** state)
{
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Search result", out);
}
void
test_ai_parse_response_text_preferred_over_content(void** state)
{
/* When both formats are present, "text" wins (Perplexity path tried first). */
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 text 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));
}