Files
cproof/tests/unittests/test_ai_client.c
jabber.developer2 3f430e01d2
Some checks failed
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Failing after 27s
CI Code / Linux (debian) (pull_request) Successful in 4m57s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m13s
CI Code / Code Coverage (pull_request) Successful in 3m10s
CI Code / Linux (arch) (pull_request) Successful in 6m36s
fix(ai): bound response parsing and harden AUTO fallback errors
- bound _parse_responses scans to the output_text part's object and to
  the content array, so a later sibling item's "text" (e.g. a reasoning
  summary) can never be returned as the assistant reply
- do not retry the other flavour on a curl timeout: the request likely
  reached the server and may still be generating, so a re-POST of the
  conversation could trigger a second billed generation
- remember an unparseable 2xx from the first AUTO attempt and surface
  stashed first-attempt errors in both error paths, instead of showing
  only the final attempt's transport or HTTP error
- recognize Ollama's model-not-found wording in _names_model so a model
  typo is not misread as a missing endpoint
- drop the dead, racy provider-lookup fallback in the generic request
  thread: a missing provider ref is a caller bug and now fails loudly
- fix ai_providers_lists_defaults to expect the header the command
  actually prints ("Configured providers:"); the test was broken since
  its introduction but CI never ran it
- add functional test group 5 (AI command surface) to FUNC_TEST_GROUPS
  so the CI parallel target runs it; proftest.c port ranges already
  account for five groups
2026-07-10 12:55:09 +03:00

1901 lines
66 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 (borrowed pointer, same as the create path) */
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);
}
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 (borrowed pointer) */
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);
}
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", "input", "[]"));
assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true"));
assert_null(ai_get_provider_setting("p_reserved_keys", "model"));
/* "store" is a legitimate chat-completions parameter and overrides the
* responses-flavour "store":false default, so it stays settable */
assert_true(ai_set_provider_setting("p_reserved_keys", "store", "true"));
auto_gchar gchar* store = ai_get_provider_setting("p_reserved_keys", "store");
assert_non_null(store);
assert_string_equal("true", store);
/* 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_keys(void** state)
{
ai_add_provider("p_setting_keys", "https://example.test/skeys/");
/* No settings yet, unknown provider, NULL — all yield NULL */
assert_null(ai_get_provider_setting_keys("p_setting_keys"));
assert_null(ai_get_provider_setting_keys("nonexistent"));
assert_null(ai_get_provider_setting_keys(NULL));
ai_set_provider_setting("p_setting_keys", "temperature", "0.7");
ai_set_provider_setting("p_setting_keys", "reasoning_effort", "high");
/* Both keys listed (order is hash-defined, assert membership) */
GList* keys = ai_get_provider_setting_keys("p_setting_keys");
assert_int_equal(2, g_list_length(keys));
assert_non_null(g_list_find_custom(keys, "temperature", (GCompareFunc)g_strcmp0));
assert_non_null(g_list_find_custom(keys, "reasoning_effort", (GCompareFunc)g_strcmp0));
g_list_free_full(keys, g_free);
/* Removing a setting removes its key */
ai_set_provider_setting("p_setting_keys", "temperature", NULL);
keys = ai_get_provider_setting_keys("p_setting_keys");
assert_int_equal(1, g_list_length(keys));
assert_string_equal("reasoning_effort", keys->data);
g_list_free_full(keys, g_free);
}
void
test_ai_set_provider_api_type(void** state)
{
ai_add_provider("p_api_type", "https://example.test/at/");
/* Default is auto */
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
/* Pin each flavour explicitly */
assert_true(ai_set_provider_api_type("p_api_type", "chat-completions"));
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_api_type"));
assert_true(ai_set_provider_api_type("p_api_type", "responses"));
assert_int_equal(AI_API_TYPE_RESPONSES, ai_get_provider_api_type("p_api_type"));
/* auto resets to detection */
assert_true(ai_set_provider_api_type("p_api_type", "auto"));
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
/* Invalid values and unknown providers are rejected */
assert_false(ai_set_provider_api_type("p_api_type", "v2"));
assert_false(ai_set_provider_api_type("p_api_type", NULL));
assert_false(ai_set_provider_api_type("nonexistent", "responses"));
assert_false(ai_set_provider_api_type(NULL, "responses"));
/* Unknown provider reads as auto */
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("nonexistent"));
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type(NULL));
}
void
test_ai_api_type_string_round_trip(void** state)
{
AIApiType type = AI_API_TYPE_AUTO;
assert_true(ai_api_type_from_string("responses", &type));
assert_int_equal(AI_API_TYPE_RESPONSES, type);
assert_string_equal("responses", ai_api_type_to_string(type));
assert_true(ai_api_type_from_string("chat-completions", &type));
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, type);
assert_string_equal("chat-completions", ai_api_type_to_string(type));
assert_true(ai_api_type_from_string("auto", &type));
assert_int_equal(AI_API_TYPE_AUTO, type);
assert_string_equal("auto", ai_api_type_to_string(type));
assert_false(ai_api_type_from_string("completions", &type));
assert_false(ai_api_type_from_string("", &type));
assert_false(ai_api_type_from_string(NULL, &type));
}
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_responses_output_text(void** state)
{
/* Responses API: "content" is an array of typed parts; the inner "text"
* of the output_text part is the assistant message. */
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_responses_full_envelope(void** state)
{
/* Realistic responses body: a reasoning item precedes the message item;
* the parser must still land on the message's output_text. */
const gchar* json = "{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\","
"\"output\":["
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]},"
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Hello!\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Hello!", out);
}
void
test_ai_parse_response_choices_win_over_output(void** state)
{
/* A chat-completions envelope is authoritative even if the content
* happens to mention "output". */
const gchar* json = "{\"choices\":[{\"message\":{\"content\":\"chat result\"}}],\"output\":[{\"content\":[{\"text\":\"wrong\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("chat result", out);
}
void
test_ai_parse_response_responses_reasoning_summary_skipped(void** state)
{
/* A reasoning item with a non-empty summary precedes the message item;
* the summary's "text" must not be mistaken for the assistant reply. */
const gchar* json = "{\"output\":["
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"User asks X\"}]},"
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Actual answer\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Actual answer", out);
}
void
test_ai_parse_response_responses_reasoning_only_returns_null(void** state)
{
/* An incomplete/truncated body with only a reasoning item (no message
* "content" array at all) must yield a parse failure, not leak the
* reasoning summary's "text" as the assistant reply. */
const gchar* json = "{\"status\":\"incomplete\",\"output\":["
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret reasoning\"}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_reasoning_after_message_skipped(void** state)
{
/* A reasoning item ordered after the message, with the part's "text"
* preceding "type":"output_text": the forward scan must stay inside the
* part's object and the backscan must find the real answer. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[{\"text\":\"answer\",\"type\":\"output_text\"}]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("answer", out);
}
void
test_ai_parse_response_responses_empty_content_not_leaked(void** state)
{
/* An empty content array followed by a reasoning item must yield a parse
* failure; the scan must not escape the array and return the summary. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_text_value_before_later_item(void** state)
{
/* A reply that is exactly "text" plus a reasoning item after the message:
* the backscan's own extraction must also stay inside the part's object. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("text", out);
}
void
test_ai_parse_response_choices_in_string_value(void** state)
{
/* "choices" occurring as a string value must not hijack a valid
* responses envelope. */
const gchar* json = "{\"kind\":\"choices\",\"output\":[{\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("hi", out);
}
void
test_ai_parse_response_content_before_choices(void** state)
{
/* Nonstandard body: a top-level content string precedes an empty
* choices array; the bare-content fallback must still find it. */
const gchar* json = "{\"content\":\"hi\",\"choices\":[]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("hi", out);
}
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_responses_brace_in_text(void** state)
{
/* Responses part with "text" before "type":"output_text" and a '{' inside
* the answer: the nearest preceding "text" key must be extracted, not a
* brace inside the string value. */
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"use { here\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("use { here", out);
}
void
test_ai_parse_response_choices_logprobs_before_content(void** state)
{
/* A non-string "content" (logprobs array) ordered before message.content
* must not defeat extraction; anchoring on "message" finds the reply. */
const gchar* json = "{\"choices\":[{\"logprobs\":{\"content\":[{\"token\":\"hi\"}]},"
"\"message\":{\"role\":\"assistant\",\"content\":\"real answer\"}}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("real answer", out);
}
void
test_ai_parse_response_reasoning_not_leaked_without_text(void** state)
{
/* An output_text part with no "text" at all (truncated body) must yield a
* parse failure, not leak the preceding reasoning summary's text. */
const gchar* json = "{\"output\":["
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]},"
"{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"annotations\":[]}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_text_value_text(void** state)
{
/* A reply that is exactly the string "text" must not confuse the
* backscan (the value's own quoted bytes match the key needle). */
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("text", 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_round_trip_api_type(void** state)
{
ai_add_provider("p_persist_api_type", "https://example.test/pat/");
assert_true(ai_set_provider_api_type("p_persist_api_type", "chat-completions"));
ai_client_shutdown();
ai_client_init();
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_persist_api_type"));
/* Setting auto removes the persisted value */
assert_true(ai_set_provider_api_type("p_persist_api_type", "auto"));
ai_client_shutdown();
ai_client_init();
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_persist_api_type"));
}
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));
}