Files
profanity/tests/unittests/test_ai_client.c
jabber.developer2 3a96d0dba1 fix(ai): close parser and AUTO fallback gaps
Parser:
- Never return reasoning text as the reply: the no-output_text path
  anchors strictly on the message "content" array; a reasoning-only body
  (truncated by max_output_tokens, carries only "summary") now fails the
  parse instead of leaking the chain-of-thought. Regression test added.
- _find_json_field skips field-name occurrences that are string values
  (no ':' after), so {"type":"text","text":"hi"} parses instead of
  failing on the value of "type".
- _same_json_object tracks brace depth, so a nested object between the
  "text" key and the "output_text" tag (e.g. "annotations") no longer
  rejects the part's own text.
- Chat-completions extraction is bounded to the choices array (new
  _json_array_end helper), so "message"/"content" in a sibling object
  (e.g. a top-level "warning") is not returned as the reply.

AUTO fallback:
- Model errors are classified only from a structured JSON error
  envelope; a route-missing 404 page merely mentioning models (e.g.
  listing /v1/models) no longer suppresses the fallback. Trade-off: a
  model error in a non-JSON body costs one extra fallback attempt.
- Fall back on a curl-level failure: an endpoint that accepts the
  connection but never responds no longer burns the 60s timeout without
  trying the flavour that works.
- Fall back on an unparseable 2xx: a gateway answering unknown paths
  with a 200 error page previously reset the hint and re-probed the same
  dead flavour on every request. The body is parsed inside the attempt
  loop and no longer re-parsed after it.
- The second attempt's 401/403/429/5xx now wins over the stashed 400/422
  payload rejection, so an invalid key or rate limit is not hidden
  behind a payload-shape error; a second 404 still defers to the first.
- When the first flavour's endpoint is missing but the other answers
  (even unsuccessfully, e.g. 401), the resolved hint moves to the
  surviving flavour, so the dead endpoint is not probed first forever.

Settings:
- Reserved payload keys are rejected only when setting a value; removal
  is allowed again, so a setting persisted before its key became
  reserved (e.g. "input") is no longer stuck in the config.
2026-07-08 20:44:02 +03:00

1859 lines
64 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_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));
}