Compare commits

..

1 Commits

Author SHA1 Message Date
e67ae4e7a5 test(ai): expand unit and functional coverage for /ai feature
Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
  - chat response parser (ai_parse_response): OpenAI content + Perplexity
    text formats, escape decoding, empty/null/missing inputs, format-string
    safety, multiline content
  - error envelope parser (ai_parse_error_message): standard envelope,
    nested escapes, missing fields, null/empty
  - extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
  - provider autocomplete cycling with >=2 matches and wrap-around
  - session edge cases: NULL args, 100-message order preservation,
    set_model(NULL), ref/unref(NULL)
  - provider edge cases: get/remove with NULL, double-remove, survival
    via session ref after ai_remove_provider
  - settings: multi-key independence, missing key, cross-provider
    isolation
  - model parsing edges: data not array, empty data, "id" outside data,
    multiple models
  - prefs round-trip: set token -> shutdown -> init -> token reloaded
    from disk (uses load_preferences fixture)

Functional tests:
  - Tier A (test_ai.c): 15 cases for the /ai command surface that don't
    need HTTP. Covers /ai help, providers list, set provider/token,
    start with/without key/unknown provider, clear, remove, default
    provider/model, switch without window, bad subcommand.
  - Tier B (test_ai_http.c + ai_http_stub.{c,h,py}): 5 cases that
    exercise the libcurl path against a local Python HTTP stub. The
    stub serves canned bodies in five modes (ok, openai, 401, 500,
    models) so each chat/models/error path is hit end-to-end without
    network.

Infrastructure:
  - TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
    Group 5 because mixing them with stabber-driven tests in Group 4
    poisoned stbbr_stop() teardown.
  - PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
    with ai_init_test() which wraps init_prof_test with a prof_connect()
    so stabber sees a graceful disconnect at teardown.
  - dist_check_DATA ships ai_http_stub.py with the source tarball.

Source-level changes to enable testing:
  - src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
    (renamed ai_parse_response) and _parse_error_response (renamed
    ai_parse_error_message); declared under a "Parsing helpers (exposed
    for testing)" section. Same approach as ai_parse_models_from_json.

Results in Docker (cproof-debian image):
  - 595/595 unit tests pass
  - 20/20 AI functional tests pass (Group 5)
2026-05-11 16:21:45 +03:00
21 changed files with 718 additions and 687 deletions

View File

@@ -204,8 +204,12 @@ functionaltest_sources = \
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
tests/functionaltests/ai_http_stub.c tests/functionaltests/ai_http_stub.h \
tests/functionaltests/test_ai_http.c tests/functionaltests/test_ai_http.h \
tests/functionaltests/functionaltests.c
dist_check_DATA = tests/functionaltests/ai_http_stub.py
main_source = src/main.c
python_sources = \

View File

@@ -21,9 +21,12 @@
#include <curl/curl.h>
#include <glib.h>
#include <pthread.h>
#include <string.h>
/* Default providers */
#define DEFAULT_OPENAI_URL "https://api.openai.com/"
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/"
/* Global state */
static GHashTable* providers = NULL;
static GHashTable* provider_keys = NULL;
@@ -32,7 +35,6 @@ static Autocomplete providers_ac = NULL;
/* ========================================================================
* Forward declarations
* ======================================================================== */
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
static void _ai_load_models_for_provider(AIProvider* provider);
static void _ai_save_models_for_provider(AIProvider* provider);
@@ -88,7 +90,9 @@ _aiwin_validate(gpointer user_data)
return NULL;
}
pthread_mutex_lock(&lock);
gboolean win_exists = wins_ai_exists((ProfAiWin*)user_data);
pthread_mutex_unlock(&lock);
if (!win_exists) {
log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data);
return NULL;
@@ -106,13 +110,12 @@ _aiwin_validate(gpointer user_data)
static void
_aiwin_display_error(gpointer user_data, const gchar* error_msg)
{
pthread_mutex_lock(&lock);
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
pthread_mutex_unlock(&lock);
return;
}
pthread_mutex_lock(&lock);
aiwin_display_error(aiwin, error_msg);
pthread_mutex_unlock(&lock);
}
@@ -130,14 +133,16 @@ _aiwin_display_response(gpointer user_data, const gchar* response)
return;
}
pthread_mutex_lock(&lock);
/* Try to display in AI window if available */
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
pthread_mutex_lock(&lock);
cons_show("%s", response);
pthread_mutex_unlock(&lock);
return;
}
pthread_mutex_lock(&lock);
aiwin_display_response(aiwin, response);
pthread_mutex_unlock(&lock);
}
@@ -189,11 +194,12 @@ ai_json_escape(const gchar* str)
* ======================================================================== */
static AIProvider*
ai_provider_new(const gchar* name, const gchar* api_url)
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
{
AIProvider* provider = g_new0(AIProvider, 1);
provider->name = g_strdup(name);
provider->api_url = g_strdup(api_url ? api_url : "");
provider->org_id = g_strdup(org_id);
provider->project_id = NULL;
provider->default_model = NULL;
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
@@ -203,7 +209,7 @@ ai_provider_new(const gchar* name, const gchar* api_url)
return provider;
}
AIProvider*
static AIProvider*
ai_provider_ref(AIProvider* provider)
{
if (provider) {
@@ -224,6 +230,7 @@ ai_provider_unref(AIProvider* provider)
g_free(provider->name);
g_free(provider->api_url);
g_free(provider->org_id);
g_free(provider->project_id);
g_free(provider->default_model);
g_hash_table_destroy(provider->settings);
@@ -280,33 +287,24 @@ ai_client_init(void)
/* Create autocomplete for provider names */
providers_ac = autocomplete_new();
/* Get all providers (defaults if nothing configured, otherwise config values) */
GList* all_providers = prefs_ai_get_providers();
for (GList* curr = all_providers; curr; curr = g_list_next(curr)) {
/* List contains pairs: name, url, name, url, ... */
gchar* name = (gchar*)curr->data;
curr = g_list_next(curr);
if (!curr)
break;
gchar* url = (gchar*)curr->data;
_ai_add_provider_nopersist(name, url);
}
prefs_free_ai_providers(all_providers);
/* Add default providers */
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
/* Load saved API keys from config */
ai_load_keys();
/* Load cached models for all providers */
GList* provider_list = ai_list_providers();
GList* models_curr = provider_list;
while (models_curr) {
AIProvider* provider = (AIProvider*)models_curr->data;
GList* curr = provider_list;
while (curr) {
AIProvider* provider = (AIProvider*)curr->data;
_ai_load_models_for_provider(provider);
models_curr = g_list_next(models_curr);
curr = g_list_next(curr);
}
g_list_free(provider_list);
log_info("AI client initialized with providers from preferences");
log_info("AI client initialized with default providers: openai, perplexity");
}
void
@@ -337,31 +335,8 @@ ai_get_provider(const gchar* name)
return g_hash_table_lookup(providers, name);
}
/* Internal helper to add provider without persisting to config.
* Used for default providers and loading from config on startup. */
static AIProvider*
_ai_add_provider_nopersist(const gchar* name, const gchar* api_url)
{
/* Check if provider already exists */
AIProvider* existing = g_hash_table_lookup(providers, name);
if (existing) {
g_free(existing->api_url);
existing->api_url = g_strdup(api_url);
return ai_provider_ref(existing);
}
/* Create new provider (ref_count=1 owned by hash table) */
AIProvider* provider = ai_provider_new(name, api_url);
g_hash_table_insert(providers, g_strdup(name), provider);
/* Sync autocomplete */
autocomplete_add(providers_ac, name);
return provider;
}
AIProvider*
ai_add_provider(const gchar* name, const gchar* api_url)
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
{
if (!name || !api_url)
return NULL;
@@ -376,17 +351,18 @@ ai_add_provider(const gchar* name, const gchar* api_url)
/* Update existing provider */
g_free(existing->api_url);
existing->api_url = g_strdup(api_url);
/* Persist the updated URL to config */
prefs_ai_set_provider(name, api_url);
g_free(existing->org_id);
existing->org_id = g_strdup(org_id);
log_info("Updated provider: %s", name);
return ai_provider_ref(existing);
}
/* Persist the new provider to config */
prefs_ai_set_provider(name, api_url);
/* Create new provider (ref_count=1 owned by hash table) */
AIProvider* provider = ai_provider_new(name, api_url, org_id);
g_hash_table_insert(providers, g_strdup(name), provider);
/* Add to hash table (reuse internal helper) */
AIProvider* provider = _ai_add_provider_nopersist(name, api_url);
/* Sync autocomplete */
autocomplete_add(providers_ac, name);
log_info("Added provider: %s (URL: %s)", name, api_url);
return provider; /* Caller gets non-owning pointer; hash table owns ref */
@@ -398,9 +374,6 @@ ai_remove_provider(const gchar* name)
if (!name || !providers)
return FALSE;
/* Remove from config before removing from hash table */
prefs_ai_remove_provider(name);
/* Sync autocomplete before removing */
autocomplete_remove(providers_ac, name);
@@ -429,26 +402,53 @@ ai_list_providers(void)
* Provider autocomplete state
* ======================================================================== */
/* Stateful provider name finder with case-sensitive matching.
* Maintains position in sorted list for deterministic tab-completion cycling.
* The autocomplete is kept in sync via ai_add_provider/ai_remove_provider. */
gchar*
ai_providers_find(const char* const search_str, gboolean previous, void* context)
{
/* Initialize autocomplete on first use */
if (!providers_ac) {
log_debug("[AI-AC] Uninitialized call to ai_providers_find");
providers_ac = autocomplete_new();
}
/* NULL search_str is treated as empty string for cycling */
const char* effective_search = (search_str != NULL) ? search_str : "";
/* Use stateful autocomplete */
return autocomplete_complete(providers_ac, effective_search, FALSE, previous);
}
/* Stateful model name finder for autocomplete.
* Uses the current session's provider models for completion. */
gchar*
ai_models_find(const char* const search_str, gboolean previous, void* context)
{
ProfAiWin* aiwin = (ProfAiWin*)context;
if (!aiwin || !aiwin->session) {
return NULL;
}
return autocomplete_complete(providers_ac, search_str, FALSE, previous);
}
/* Reset the provider autocomplete state.
* Called from cmd_ac_reset() to clear autocomplete state when the user presses Ctrl+C. */
void
ai_providers_reset_ac(void)
{
if (!providers_ac) {
return;
AIProvider* provider = aiwin->session->provider;
if (!provider || !provider->models) {
return NULL;
}
autocomplete_reset(providers_ac);
/* Create a temporary autocomplete for models */
Autocomplete models_ac = autocomplete_new();
GList* curr = provider->models;
while (curr) {
autocomplete_add(models_ac, curr->data);
curr = g_list_next(curr);
}
/* NULL search_str is treated as empty string for cycling */
const char* effective_search = (search_str != NULL) ? search_str : "";
gchar* result = autocomplete_complete(models_ac, effective_search, FALSE, previous);
autocomplete_free(models_ac);
return result;
}
gchar*
@@ -571,7 +571,7 @@ typedef struct
} ai_request_t;
/**
* Build curl headers with auth.
* Build curl headers with auth and optional org header.
* Returns headers list (caller must free with curl_slist_free_all).
*/
static struct curl_slist*
@@ -582,6 +582,11 @@ _build_curl_headers(AIProvider* provider, const gchar* api_key)
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, auth_header);
if (provider && provider->org_id && strlen(provider->org_id) > 0) {
auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", provider->org_id);
headers = curl_slist_append(headers, org_header);
}
return headers;
}
@@ -633,10 +638,6 @@ _ai_generic_request_thread(gpointer data)
return NULL;
}
/* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider
* struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */
ai_provider_ref(provider);
CURL* curl = curl_easy_init();
if (!curl) {
log_error("Failed to initialize curl for %s", req->provider_name);
@@ -667,10 +668,6 @@ _ai_generic_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
g_free(response.data);
/* Release the reference taken after lookup — provider may now be freed. */
ai_provider_unref(provider);
g_free(req->provider_name);
g_free(req->request_url);
g_free(req);
@@ -1053,11 +1050,6 @@ ai_session_create(const gchar* provider_name, const gchar* model)
}
AISession* session = g_new0(AISession, 1);
if (pthread_mutex_init(&session->lock, NULL) != 0) {
log_error("Failed to initialize session mutex");
g_free(session);
return NULL;
}
session->provider_name = g_strdup(provider_name);
session->provider = ai_provider_ref(provider);
session->model = g_strdup(model);
@@ -1103,8 +1095,6 @@ ai_session_unref(AISession* session)
}
g_list_free(session->history);
pthread_mutex_destroy(&session->lock);
g_free(session);
log_debug("AI session destroyed");
}
@@ -1115,12 +1105,10 @@ ai_session_add_message(AISession* session, const gchar* role, const gchar* conte
if (!session || !role || !content)
return;
pthread_mutex_lock(&session->lock);
AIMessage* msg = g_new0(AIMessage, 1);
msg->role = g_strdup(role);
msg->content = g_strdup(content);
session->history = g_list_append(session->history, msg);
pthread_mutex_unlock(&session->lock);
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
}
@@ -1131,7 +1119,6 @@ ai_session_clear_history(AISession* session)
if (!session)
return;
pthread_mutex_lock(&session->lock);
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
@@ -1142,7 +1129,6 @@ ai_session_clear_history(AISession* session)
}
g_list_free(session->history);
session->history = NULL;
pthread_mutex_unlock(&session->lock);
log_info("AI session history cleared");
}
@@ -1161,64 +1147,24 @@ ai_session_set_model(AISession* session, const gchar* model)
if (!session || !model)
return;
pthread_mutex_lock(&session->lock);
g_free(session->model);
session->model = g_strdup(model);
pthread_mutex_unlock(&session->lock);
log_info("Session model changed to: %s", model);
}
void
ai_session_switch(AISession* session, const gchar* provider_name,
const gchar* model, gchar* api_key)
{
if (!session || !provider_name || !model || !api_key)
return;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_warning("Provider '%s' not found for session switch", provider_name);
g_free(api_key);
return;
}
pthread_mutex_lock(&session->lock);
g_free(session->provider_name);
session->provider_name = g_strdup(provider_name);
ai_provider_unref(session->provider);
session->provider = ai_provider_ref(provider);
g_free(session->model);
session->model = g_strdup(model);
g_free(session->api_key);
session->api_key = g_strdup(api_key);
pthread_mutex_unlock(&session->lock);
g_free(api_key);
log_info("Session switched to %s/%s", provider_name, model);
}
/* ========================================================================
* API Request Handling
* ======================================================================== */
/**
* Build JSON payload from a GList of AIMessage nodes and a prompt string.
* This is the thread-safe variant used by _ai_request_thread() which holds
* the session lock while calling it.
*
* @param model The model identifier
* @param history GList of AIMessage* (must be held under session lock)
* @param prompt The new user prompt to append
* @return Newly allocated JSON string (caller must free)
*/
static gchar*
_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt)
_build_json_payload(AISession* session, const gchar* prompt)
{
/* OpenAI-compatible Responses API format:
* {"model": "...", "input": [...], "stream": false, "store": false}
* store:false prevents providers from storing/using requests for training */
GString* messages_json = g_string_new("");
GList* curr = history;
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
@@ -1241,7 +1187,7 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
}
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
auto_gchar gchar* escaped_model = ai_json_escape(model);
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
gchar* json_payload = g_strdup_printf(
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
escaped_model, messages_json->str);
@@ -1402,31 +1348,14 @@ _ai_request_thread(gpointer data)
auto_gchar gchar* prompt = args[1];
gpointer user_data = args[2];
/* =====================================================================
* CRITICAL: Copy all session state under lock to prevent UAF races with
* cmd_ai_switch() and ai_session_clear_history() which mutate session
* fields on the main thread without holding a session ref.
*
* The worker holds a ref to the session (via ai_session_ref in
* ai_send_prompt), which prevents the session struct from being freed,
* but does NOT prevent field mutations (g_free/g_strdup of strings,
* list modifications). We must snapshot all fields atomically.
* ===================================================================== */
pthread_mutex_lock(&session->lock);
gchar* local_provider_name = g_strdup(session->provider_name);
AIProvider* local_provider = ai_provider_ref(session->provider);
gchar* local_model = g_strdup(session->model);
gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
pthread_mutex_unlock(&session->lock);
log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model);
log_debug("[AI-THREAD] API key length: %zu", strlen(local_api_key));
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
/* Check for API key first */
if (!local_api_key || strlen(local_api_key) == 0) {
if (!session->api_key || strlen(session->api_key) == 0) {
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
local_provider_name, local_provider_name);
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
session->provider_name, session->provider_name);
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
g_free(args);
return NULL;
@@ -1436,28 +1365,23 @@ _ai_request_thread(gpointer data)
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
if (!curl) {
log_error("AI request failed for %s/%s: Failed to initialize curl",
local_provider_name, local_model);
session->provider_name, session->model);
_aiwin_display_error(user_data, "Failed to initialize curl.");
g_free(args);
return NULL;
}
/* Build JSON payload from history + prompt (under lock), then add user message to history */
log_debug("[AI-THREAD] Building JSON payload...");
pthread_mutex_lock(&session->lock);
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
/* Add user message to history now (it's in JSON but not yet in history) */
AIMessage* user_msg = g_new0(AIMessage, 1);
user_msg->role = g_strdup("user");
user_msg->content = g_strdup(prompt);
session->history = g_list_append(session->history, user_msg);
pthread_mutex_unlock(&session->lock);
/* Add user message to history FIRST */
ai_session_add_message(session, "user", prompt);
log_debug("[AI-THREAD] Added user message to history");
/* Build JSON payload (includes the message we just added) */
log_debug("[AI-THREAD] Building JSON payload...");
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
/* Build headers using shared helper */
struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key);
struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key);
/* Response buffer */
struct curl_response_t response;
@@ -1465,11 +1389,11 @@ _ai_request_thread(gpointer data)
response.size = 0;
/* Build request URL */
const gchar* api_url = local_provider->api_url;
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
log_debug("[AI-THREAD] API URL: %s", api_url);
log_debug("[AI-THREAD] API Request URL: %s", request_url);
log_debug("[AI-THREAD] Model: %s", local_model);
log_debug("[AI-THREAD] Model: %s", session->model);
/* Set URL and execute request */
curl_easy_setopt(curl, CURLOPT_URL, request_url);
@@ -1489,7 +1413,7 @@ _ai_request_thread(gpointer data)
if (res != CURLE_OK) {
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else if (http_code >= 400) {
/* Handle HTTP errors */
@@ -1498,7 +1422,7 @@ _ai_request_thread(gpointer data)
/* Try to extract the actual error message from the JSON response */
auto_gchar gchar* parsed_error = ai_parse_error_message(response.data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
/* Parse response - transfer ownership to auto_gchar for cleanup */
@@ -1507,17 +1431,12 @@ _ai_request_thread(gpointer data)
response.data = NULL;
auto_gchar gchar* content = ai_parse_response(response_data);
if (content) {
/* Add assistant response to history (under lock) */
pthread_mutex_lock(&session->lock);
AIMessage* assistant_msg = g_new0(AIMessage, 1);
assistant_msg->role = g_strdup("assistant");
assistant_msg->content = g_strdup(content);
session->history = g_list_append(session->history, assistant_msg);
pthread_mutex_unlock(&session->lock);
/* Add assistant response to history */
ai_session_add_message(session, "assistant", content);
_aiwin_display_response(user_data, content);
} else {
log_error("AI response parse failed for %s/%s: %.200s...",
local_provider_name, local_model, response_data);
session->provider_name, session->model, response_data);
_aiwin_display_error(user_data, "Failed to parse AI response.");
}
}
@@ -1525,12 +1444,6 @@ _ai_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
/* Cleanup local copies */
g_free(local_provider_name);
ai_provider_unref(local_provider);
g_free(local_model);
g_free(local_api_key);
g_free(args);
return NULL;

View File

@@ -19,6 +19,7 @@ typedef struct ai_provider_t
{
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* org_id; /* Optional organization ID */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
@@ -32,7 +33,6 @@ typedef struct ai_provider_t
*/
typedef struct ai_session_t
{
pthread_mutex_t lock; /* Protects all session fields below */
gchar* provider_name; /* Provider name */
AIProvider* provider; /* Provider configuration */
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
@@ -66,9 +66,10 @@ AIProvider* ai_get_provider(const gchar* name);
* Add or update a provider configuration.
* @param name The provider name
* @param api_url The API endpoint URL
* @param org_id Optional organization ID (can be NULL)
* @return New AIProvider* (caller must unref when done)
*/
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
/**
* Remove a provider by name.
@@ -77,13 +78,6 @@ AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
*/
gboolean ai_remove_provider(const gchar* name);
/**
* Increment the reference count of a provider.
* @param provider The provider to reference
* @return The same provider pointer
*/
AIProvider* ai_provider_ref(AIProvider* provider);
/**
* Decrement the reference count of a provider.
* @param provider The provider to unreference
@@ -115,12 +109,6 @@ gchar* ai_providers_find(const char* const search_str, gboolean previous, void*
*/
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
/**
* Reset the provider autocomplete state.
* Called from cmd_ac_reset() to clear autocomplete state.
*/
void ai_providers_reset_ac(void);
/**
* Get the API key for a provider.
* @param provider_name The provider name
@@ -263,19 +251,6 @@ const gchar* ai_session_get_model(AISession* session);
*/
void ai_session_set_model(AISession* session, const gchar* model);
/**
* Atomically switch session provider, model, and API key.
* All mutations happen under the session lock to prevent races with
* _ai_request_thread() which snapshots session state before making requests.
*
* @param session The session
* @param provider_name New provider name
* @param model New model identifier
* @param api_key New API key (caller must free after calling this)
*/
void ai_session_switch(AISession* session, const gchar* provider_name,
const gchar* model, gchar* api_key);
/* ========================================================================
* Request Handling
* ======================================================================== */

View File

@@ -70,6 +70,7 @@
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix);
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
@@ -468,8 +469,7 @@ static Autocomplete* all_acs[] = {
&ai_subcommands_ac,
&ai_set_subcommands_ac,
&ai_set_custom_subcommands_ac,
&ai_remove_subcommands_ac,
&ai_models_ac
&ai_remove_subcommands_ac
};
static GHashTable* ac_funcs = NULL;
@@ -1192,6 +1192,7 @@ cmd_ac_init(void)
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
@@ -1696,7 +1697,6 @@ cmd_ac_reset(ProfWin* window)
win_reset_search_attempts();
win_close_reset_search_attempts();
plugins_reset_autocomplete();
ai_providers_reset_ac();
}
void
@@ -4487,10 +4487,6 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
return result;
}
/* Forward declaration */
static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input,
gboolean previous, const char* cmd);
static char*
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
@@ -4499,6 +4495,7 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
gboolean space_at_end = g_str_has_suffix(input, " ");
int num_args = g_strv_length(args);
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
@@ -4513,6 +4510,12 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai set default-provider <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-provider", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-model <provider> <model> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL);
if (result) {
@@ -4554,20 +4557,34 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai start <provider> <model> - autocomplete provider names and model names
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai start");
// /ai start [<provider>] [<model>] - use shared parse_args
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
} else {
/* /ai start <provider> <model> - model autocomplete */
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
}
if (result) {
return result;
}
}
// /ai switch <provider> <model> - autocomplete model names for specified provider
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
if (result) {
return result;
}
// /ai switch <provider> <model> - autocomplete provider names and model names
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai switch");
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-model <provider> <model> - autocomplete provider names and model names
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai set default-model");
// /ai switch <model> - autocomplete model names from current session's provider
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
if (result) {
return result;
}
@@ -4587,26 +4604,13 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
}
/**
* Autocomplete provider names and model names for /ai <cmd> <provider> <model> patterns.
* First tries to autocomplete provider names, then model names if provider is valid.
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
* Extracts provider from input and provides model completions from that provider's cached models.
* Uses static ai_models_ac to preserve cycling state (last_found) across calls.
* The cmd_prefix should NOT include trailing space (e.g., "/ai start" not "/ai start ").
*/
static char*
_ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd)
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
{
char* result;
/* First, try provider name autocomplete */
result = autocomplete_param_with_func(input, cmd, ai_providers_find, previous, NULL);
if (result) {
return result;
}
/* Provider was specified, try model name autocomplete */
/* Build the full command prefix with space */
auto_gchar gchar* cmd_prefix = g_strdup_printf("%s ", cmd);
if (!g_str_has_prefix(input, cmd_prefix)) {
return NULL;
}
@@ -4642,6 +4646,6 @@ _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gb
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
return result;
}

View File

@@ -2804,6 +2804,7 @@ static const struct cmd_t command_defs[] = {
"/ai",
"/ai set provider <name> <url>",
"/ai set token <provider> <token>",
"/ai set default-provider <provider>",
"/ai set default-model <provider> <model>",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
@@ -2822,12 +2823,14 @@ static const struct cmd_t command_defs[] = {
{ "", "Display current AI settings and configured providers" },
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set default-provider <provider>", "Set global default provider for /ai start" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "remove provider <name>", "Remove a custom provider" },
{ "providers", "List configured providers with full details" },
{ "start <provider> [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
{ "switch <model>", "Change model in current session (keeps provider)" },
{ "models <provider>", "Fetch and display available models for provider" },
{ "clear", "Clear current chat history" })
CMD_EXAMPLES(
@@ -2835,6 +2838,7 @@ static const struct cmd_t command_defs[] = {
"/ai set token openai sk-xxx",
"/ai set token perplexity pplx-xxx",
"/ai set provider custom https://my-api.com/v1/chat/completions",
"/ai set default-provider perplexity",
"/ai set default-model perplexity sonar",
"/ai set custom perplexity tools enabled",
"/ai remove provider custom",

View File

@@ -8516,7 +8516,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id());
cl_ev_send_ai_msg(aiwin, inp, NULL);
break;
}
default:
@@ -10839,7 +10839,7 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_bad_cmd_usage(command);
return TRUE;
}
if (ai_add_provider(args[2], args[3])) {
if (ai_add_provider(args[2], args[3], NULL)) {
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
} else {
cons_show_error("Failed to configure provider '%s'.", args[2]);
@@ -10876,6 +10876,23 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "default-provider") == 0) {
// /ai set default-provider <provider>
if (g_strv_length(args) < 3) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* provider_name = args[2];
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
provider_name, provider_name);
return TRUE;
}
prefs_set_string(PREF_AI_PROVIDER, provider_name);
cons_show("Default provider set to: %s", provider_name);
cons_show("");
return TRUE;
}
cons_bad_cmd_usage(command);
@@ -10944,11 +10961,8 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
if (!model) {
model = ai_get_provider_default_model(provider_name);
}
if (!model) {
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
cons_show("Use '/ai set default-model %s <model>'.", provider_name);
return TRUE;
model = "gpt-4o"; // Fallback
}
// Check for API key
@@ -11010,12 +11024,12 @@ cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
const gchar* model = args[1];
// Must be in an AI window to use this command
if (!window || window->type != WIN_AI) {
cons_show_error("Must be in an AI chat window to use this command.");
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
@@ -11041,12 +11055,12 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
// Must be in an AI window to use this command
if (!window || window->type != WIN_AI) {
cons_show_error("Must be in an AI chat window to use this command.");
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
@@ -11060,12 +11074,14 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
const gchar* provider_name;
const gchar* model;
gboolean changed_provider = FALSE;
// Check if arg1 is a known provider
AIProvider* provider = ai_get_provider(arg1);
if (provider) {
// arg1 is a provider name
provider_name = arg1;
changed_provider = TRUE;
// Get model: arg2 > provider default > error
if (arg2) {
@@ -11081,10 +11097,6 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
} else {
// arg1 is a model name, keep current provider
provider_name = aiwin->session->provider_name;
if (!provider_name) {
cons_show_error("Session has no provider name.");
return TRUE;
}
model = arg1;
}
@@ -11096,8 +11108,19 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
// Atomically switch session provider, model, and API key (thread-safe)
ai_session_switch(aiwin->session, provider_name, model, g_steal_pointer(&api_key));
// Update the existing session's provider and model
if (changed_provider) {
AIProvider* new_provider = ai_get_provider(provider_name);
g_free(aiwin->session->provider_name);
aiwin->session->provider_name = g_strdup(provider_name);
ai_provider_unref(aiwin->session->provider);
aiwin->session->provider = new_provider;
aiwin->session->provider->ref_count++;
}
g_free(aiwin->session->model);
aiwin->session->model = g_strdup(model);
g_free(aiwin->session->api_key);
aiwin->session->api_key = g_strdup(api_key);
// Update window title
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
@@ -11144,7 +11167,8 @@ cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
} else {
// Default: fetch fresh models from API
ai_fetch_models(provider_name, window);
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
ai_fetch_models(provider_name, aiwin);
}
return TRUE;
@@ -11154,20 +11178,70 @@ gboolean
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
{
// /ai clear
// Must be in an AI window to use this command
if (!window || window->type != WIN_AI) {
cons_show_error("Must be in an AI chat window to use this command.");
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (aiwin) {
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (aiwin->session) {
ai_session_clear_history(aiwin->session);
}
cons_show("Chat history cleared.");
} else {
cons_show("No active AI chat window to clear.");
}
cons_show("");
return TRUE;
}
gboolean
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
{
// /ai correct <message>
// Join all arguments from args[1] onwards to support multi-word prompts
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
if (prompt == NULL || strlen(prompt) == 0) {
cons_bad_cmd_usage(command);
return TRUE;
}
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (aiwin->session) {
ai_session_clear_history(aiwin->session);
// Get current AI window
ProfAiWin* aiwin = wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
return TRUE;
}
win_clear(window);
cons_show("AI Chat history cleared.");
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
// Get the last user message and replace it
GList* history = aiwin->session->history;
GList* last_user_msg = NULL;
for (GList* curr = history; curr; curr = g_list_next(curr)) {
AIMessage* msg = curr->data;
if (g_strcmp0(msg->role, "user") == 0) {
last_user_msg = curr;
}
}
if (!last_user_msg) {
cons_show("No user messages in this chat to correct.");
return TRUE;
}
// Replace the last user message
AIMessage* msg = last_user_msg->data;
g_free(msg->content);
msg->content = g_strdup(prompt);
// Resend the prompt
cons_show("Correcting message...");
ai_send_prompt(aiwin->session, prompt, aiwin);
return TRUE;
}
@@ -11196,6 +11270,9 @@ cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
cons_show(" %s", provider->name);
cons_show(" URL: %s", provider->api_url);
cons_show(" Key: %s", key ? "configured" : "NOT configured");
if (provider->org_id && strlen(provider->org_id) > 0) {
cons_show(" Org: %s", provider->org_id);
}
cons_show("");
g_free(key);
}

View File

@@ -1863,128 +1863,6 @@ prefs_free_ai_models(GList* models)
}
}
/* ========================================================================
* AI Provider Management
* ======================================================================== */
gboolean
prefs_ai_set_provider(const char* const provider, const char* const url)
{
if (!provider || !url)
return FALSE;
if (!prefs)
return FALSE;
/* Store URL with "_url" suffix to avoid collision with API key storage */
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_url", NULL), url);
_save_prefs();
return TRUE;
}
gboolean
prefs_ai_remove_provider(const char* const provider)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
auto_gchar gchar* url_key = g_strconcat(provider, "_url", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, url_key, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, url_key, NULL);
_save_prefs();
return TRUE;
}
char*
prefs_ai_get_provider_url(const char* const provider)
{
if (!provider || !prefs)
return NULL;
auto_gchar gchar* key = g_strconcat(provider, "_url", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return NULL;
}
return g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
}
GList*
prefs_ai_list_providers(void)
{
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
return NULL;
}
GList* result = NULL;
gsize len = 0;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
for (gsize i = 0; i < len; i++) {
char* key = keys[i];
/* Match keys ending with "_url" that are not "_models" suffix */
if (g_str_has_suffix(key, "_url") && !g_str_has_suffix(key, "_models_url")) {
/* Extract provider name by removing "_url" suffix */
gsize key_len = strlen(key);
if (key_len > 4) {
gchar* provider_name = g_strndup(key, key_len - 4);
result = g_list_append(result, provider_name);
}
}
}
return result;
}
void
prefs_free_ai_providers(GList* providers)
{
if (providers) {
g_list_free_full(providers, g_free);
}
}
GList*
prefs_ai_get_providers(void)
{
/* If user has configured any providers, return those */
GList* configured = prefs_ai_list_providers();
if (configured && g_list_length(configured) > 0) {
/* Build result list with name, url pairs */
GList* result = NULL;
GList* curr = configured;
while (curr) {
gchar* name = (gchar*)curr->data;
gchar* url = prefs_ai_get_provider_url(name);
if (url && strlen(url) > 0) {
result = g_list_append(result, g_strdup(name));
result = g_list_append(result, g_strdup(url));
}
g_free(url);
curr = g_list_next(curr);
}
prefs_free_ai_providers(configured);
return result;
}
prefs_free_ai_providers(configured);
prefs_ai_set_provider("openai", "https://api.openai.com/");
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
GList* result = NULL;
result = g_list_append(result, g_strdup("openai"));
result = g_list_append(result, g_strdup("https://api.openai.com/"));
result = g_list_append(result, g_strdup("perplexity"));
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
return result;
}
// get the preference group for a specific preference
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
// to the [ui] section.

View File

@@ -366,14 +366,6 @@ char* prefs_ai_get_token(const char* const provider);
GList* prefs_ai_list_tokens(void);
void prefs_free_ai_tokens(GList* tokens);
/* AI provider management */
gboolean prefs_ai_set_provider(const char* const provider, const char* const url);
gboolean prefs_ai_remove_provider(const char* const provider);
char* prefs_ai_get_provider_url(const char* const provider);
GList* prefs_ai_list_providers(void);
void prefs_free_ai_providers(GList* providers);
GList* prefs_ai_get_providers(void);
/* AI model cache management */
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
gboolean prefs_ai_remove_models(const char* const provider);

View File

@@ -260,7 +260,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
FREE_SET_NULL(ac->search_str);
}
ac->search_str = g_strdup(search_str);
ac->search_str = strdup(search_str);
found = _search(ac, ac->items, quote, NEXT);
return found;
@@ -305,7 +305,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
// autocomplete_func func is used -> autocomplete_param_with_func
// Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac
static char*
_autocomplete_param_common(const char* const input, const char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
_autocomplete_param_common(const char* const input, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
{
int len;
auto_gchar gchar* command_cpy = g_strdup_printf("%s ", command);
@@ -344,7 +344,7 @@ _autocomplete_param_common(const char* const input, const char* command, autocom
}
char*
autocomplete_param_with_func(const char* const input, const char* command, autocomplete_func func, gboolean previous, void* context)
autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context)
{
return _autocomplete_param_common(input, command, func, NULL, FALSE, previous, context);
}

View File

@@ -63,7 +63,7 @@ gchar* autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean
GList* autocomplete_create_list(Autocomplete ac);
gint autocomplete_length(Autocomplete ac);
char* autocomplete_param_with_func(const char* const input, const char* command,
char* autocomplete_param_with_func(const char* const input, char* command,
autocomplete_func func, gboolean previous, void* context);
char* autocomplete_param_with_ac(const char* const input, char* command,

View File

@@ -867,6 +867,24 @@ wins_get_prune_wins(void)
return result;
}
ProfAiWin*
wins_get_ai(void)
{
GList* curr = values;
while (curr) {
ProfWin* window = curr->data;
if (window->type == WIN_AI) {
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
return aiwin;
}
curr = g_list_next(curr);
}
return NULL;
}
gboolean
wins_ai_exists(ProfAiWin* const aiwin)
{

View File

@@ -63,6 +63,7 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
ProfPluginWin* wins_get_plugin(const char* const tag);
ProfXMLWin* wins_get_xmlconsole(void);
ProfVcardWin* wins_get_vcard(void);
ProfAiWin* wins_get_ai(void);
gboolean wins_ai_exists(ProfAiWin* const aiwin);
void wins_close_plugin(char* tag);

View File

@@ -0,0 +1,100 @@
/*
* ai_http_stub.c
*
* Spawns tests/functionaltests/ai_http_stub.py as a child process for
* functional tests that exercise the AI client's HTTP code path. The
* stub listens on 127.0.0.1 on an OS-chosen port and prints "PORT=<n>"
* as its first line; we read it back and hand the port to the caller.
*/
#include "ai_http_stub.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define STUB_SCRIPT "../tests/functionaltests/ai_http_stub.py"
#define READLINE_MAX 64
static pid_t stub_pid = 0;
int
ai_http_stub_start(const char* mode, const char* reply)
{
int pipefd[2];
if (pipe(pipefd) != 0) {
return 0;
}
pid_t pid = fork();
if (pid < 0) {
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
if (pid == 0) {
/* Child: redirect stdout to the pipe write end, then exec the stub. */
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
if (mode) {
setenv("AI_STUB_MODE", mode, 1);
}
if (reply) {
setenv("AI_STUB_REPLY", reply, 1);
}
/* Argument "0" -> let the OS pick a free port. */
execlp("python3", "python3", STUB_SCRIPT, "0", (char*)NULL);
/* Falls through on exec failure. */
_exit(127);
}
/* Parent: read "PORT=<n>\n" from the child's stdout. */
close(pipefd[1]);
char buf[READLINE_MAX] = { 0 };
ssize_t total = 0;
while (total < (ssize_t)sizeof(buf) - 1) {
ssize_t n = read(pipefd[0], buf + total, sizeof(buf) - 1 - total);
if (n <= 0) {
break;
}
total += n;
if (memchr(buf, '\n', total)) {
break;
}
}
close(pipefd[0]);
int port = 0;
if (sscanf(buf, "PORT=%d", &port) != 1 || port <= 0) {
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 0;
}
stub_pid = pid;
return port;
}
void
ai_http_stub_stop(void)
{
if (stub_pid <= 0) {
return;
}
kill(stub_pid, SIGTERM);
/* Give the stub a moment to exit cleanly, then reap. */
int status = 0;
pid_t waited = waitpid(stub_pid, &status, 0);
(void)waited;
(void)status;
stub_pid = 0;
}

View File

@@ -0,0 +1,25 @@
#ifndef __H_AI_HTTP_STUB
#define __H_AI_HTTP_STUB
#include <sys/types.h>
/*
* Spawn the Python AI stub script (tests/functionaltests/ai_http_stub.py).
*
* mode — one of "ok", "openai", "401", "500", "models" (see the stub
* script). Selects the response body and HTTP status.
* reply — body text used by "ok"/"openai"/"401" modes. Pass NULL for
* the default ("MOCK-REPLY").
*
* Returns the listening port (>0) on success, 0 on failure. The child's
* pid is recorded internally; call ai_http_stub_stop() to terminate it.
*/
int ai_http_stub_start(const char* mode, const char* reply);
/*
* Send SIGTERM to the most recently spawned stub and reap it.
* No-op if no stub is running.
*/
void ai_http_stub_stop(void);
#endif

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Minimal HTTP stub for AI functional tests.
Listens on 127.0.0.1:<port> and serves canned JSON responses for any POST.
Behaviour is selected via the URL path and the AI_STUB_MODE environment
variable, set by the test runner before exec().
Modes:
- "ok" : 200 with a valid OpenAI Responses-API body
({"output":[{"content":[{"text":"...","type":"output_text"}]}]})
- "openai" : 200 with a legacy OpenAI Chat Completions body
({"choices":[{"message":{"content":"..."}}]})
- "401" : 401 with an OpenAI-style error envelope
- "500" : 500 with a body that triggers the parse-error fallback
- "models" : 200 with a /v1/models list body
The reply text is taken from AI_STUB_REPLY (default: "MOCK-REPLY").
The stub writes its actual listening port to stdout as a single line
"PORT=<n>" so the parent can read it back, then runs until SIGTERM.
"""
import json
import os
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
REPLY = os.environ.get("AI_STUB_REPLY", "MOCK-REPLY")
MODE = os.environ.get("AI_STUB_MODE", "ok")
def _dumps(body: object) -> bytes:
"""Produce compact JSON without whitespace — profanity's hand-rolled
parser does strstr for `"key":"`, which fails when json.dumps inserts
a space after the colon by default."""
return json.dumps(body, separators=(",", ":")).encode("utf-8")
def _body_for(mode: str) -> tuple[int, bytes]:
if mode == "ok":
body = {
"output": [
{
"content": [
{"text": REPLY, "type": "output_text"}
]
}
]
}
return 200, _dumps(body)
if mode == "openai":
body = {
"choices": [
{"message": {"role": "assistant", "content": REPLY}}
]
}
return 200, _dumps(body)
if mode == "401":
body = {
"error": {
"message": REPLY,
"type": "invalid_request_error",
"code": "invalid_api_key",
}
}
return 401, _dumps(body)
if mode == "500":
return 500, b"<html><body>Internal Server Error</body></html>"
if mode == "models":
body = {
"object": "list",
"data": [
{"id": "model-a", "object": "model"},
{"id": "model-b", "object": "model"},
{"id": "model-c", "object": "model"},
],
}
return 200, _dumps(body)
return 200, b"{}"
class StubHandler(BaseHTTPRequestHandler):
def _send(self):
status, body = _body_for(MODE)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
if length:
self.rfile.read(length)
self._send()
def do_GET(self):
self._send()
def log_message(self, fmt, *args):
return
def main() -> None:
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
srv = HTTPServer(("127.0.0.1", port), StubHandler)
actual = srv.server_address[1]
print(f"PORT={actual}", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

View File

@@ -60,6 +60,7 @@
#include "test_export_import.h"
#endif
#include "test_ai.h"
#include "test_ai_http.h"
/* Macro to wrap each test with setup/teardown functions */
#define PROF_FUNC_TEST(test) \
@@ -324,7 +325,7 @@ main(int argc, char* argv[])
* prof_connect() calls.
* ============================================================ */
const struct CMUnitTest group5_tests[] = {
/* Console-only — no network calls */
/* Tier A — no network calls */
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
@@ -340,6 +341,13 @@ main(int argc, char* argv[])
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
/* Tier B — full request/response through Python HTTP stub */
PROF_FUNC_TEST_AI(ai_http_prompt_returns_ok),
PROF_FUNC_TEST_AI(ai_http_prompt_openai_format),
PROF_FUNC_TEST_AI(ai_http_prompt_401_shows_error),
PROF_FUNC_TEST_AI(ai_http_prompt_500_surfaced),
PROF_FUNC_TEST_AI(ai_http_models_fetch_lists_models),
};
/* Test group registry for easy extension */
@@ -353,7 +361,7 @@ main(int argc, char* argv[])
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
{ "Group 5: AI command surface (console only)", group5_tests, ARRAY_SIZE(group5_tests) },
{ "Group 5: AI command surface (Tier A + Tier B)", group5_tests, ARRAY_SIZE(group5_tests) },
};
const int num_groups = ARRAY_SIZE(groups);

View File

@@ -0,0 +1,157 @@
/*
* test_ai_http.c
*
* Functional tests for the /ai command surface that exercise the libcurl
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
* per-test on a free local port; profanity is configured to point at it
* via `/ai set provider`. The stub serves canned JSON whose shape is
* controlled via AI_STUB_MODE.
*
* These tests intentionally do not race the worker thread directly —
* they rely on prof_timeout() retries to catch the response once it
* propagates back through the AI window.
*/
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include "proftest.h"
#include "ai_http_stub.h"
#define WAIT_AI_RESPONSE_SEC 10
/*
* Configure profanity to use the spawned stub as a provider named "mock"
* with a dummy bearer token, then issue /ai start to open the AI window.
* Returns the stub port on success, fails the test on stub-spawn failure.
*/
static int
_setup_mock_provider(const char* mode, const char* reply)
{
int port = ai_http_stub_start(mode, reply);
assert_int_not_equal(0, port);
char cmd[256];
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
prof_input(cmd);
prof_timeout(5);
assert_true(prof_output_regex("Provider 'mock' configured"));
prof_timeout_reset();
prof_input("/ai set token mock fake-test-token");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: mock"));
prof_timeout_reset();
return port;
}
void
ai_http_prompt_returns_ok(void** state)
{
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
_setup_mock_provider("ok", "STUB-HELLO");
prof_input("/ai start mock testmodel");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
prof_timeout_reset();
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
prof_input("hello stub");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("STUB-HELLO"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_openai_format(void** state)
{
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
_setup_mock_provider("openai", "FROM-CHOICES");
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("anything");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("FROM-CHOICES"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_401_shows_error(void** state)
{
/* 401 with an OpenAI-style error envelope.
* ai_parse_error_message() should extract the inner message; the AI
* window then displays "HTTP 401: <message>". */
_setup_mock_provider("401", "Bad token, try another");
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("prompt");
prof_timeout(WAIT_AI_RESPONSE_SEC);
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_500_surfaced(void** state)
{
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
_setup_mock_provider("500", NULL);
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("trigger");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("HTTP 500"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_models_fetch_lists_models(void** state)
{
/* /v1/models endpoint returns a list of three models. /ai models <p>
* caches them silently; /ai models <p> --cached then displays the names. */
_setup_mock_provider("models", NULL);
prof_input("/ai models mock");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
prof_timeout_reset();
prof_input("/ai models mock --cached");
prof_timeout(5);
assert_true(prof_output_regex("model-a"));
assert_true(prof_output_regex("model-b"));
assert_true(prof_output_regex("model-c"));
prof_timeout_reset();
ai_http_stub_stop();
}

View File

@@ -0,0 +1,10 @@
#ifndef __H_FUNC_TEST_AI_HTTP
#define __H_FUNC_TEST_AI_HTTP
void ai_http_prompt_returns_ok(void** state);
void ai_http_prompt_openai_format(void** state);
void ai_http_prompt_401_shows_error(void** state);
void ai_http_prompt_500_surfaced(void** state);
void ai_http_models_fetch_lists_models(void** state);
#endif

View File

@@ -47,15 +47,17 @@ 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");
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org");
assert_non_null(provider);
assert_string_equal("custom", provider->name);
assert_string_equal("https://custom.api.com/v1", provider->api_url);
assert_string_equal("my-org", provider->org_id);
/* Update existing provider (returns ref; caller owns it) */
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1");
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
assert_non_null(updated);
assert_string_equal("https://new.api.com/v1", updated->api_url);
assert_null(updated->org_id);
ai_provider_unref(updated);
}
@@ -67,7 +69,7 @@ test_ai_remove_provider(void** state)
assert_false(ai_remove_provider("nonexistent"));
/* Add and remove custom provider */
ai_add_provider("temp", "https://temp.api.com/v1");
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
assert_true(ai_remove_provider("temp"));
assert_null(ai_get_provider("temp"));
}
@@ -83,7 +85,7 @@ test_ai_list_providers(void** state)
g_list_free(providers);
/* Add another provider */
ai_add_provider("test", "https://test.api.com/v1");
ai_add_provider("test", "https://test.api.com/v1", NULL);
providers = ai_list_providers();
assert_int_equal(3, g_list_length(providers));
@@ -302,27 +304,29 @@ 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");
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1");
assert_non_null(provider);
assert_string_equal("https://first.api.com/v1", provider->api_url);
assert_string_equal("org1", provider->org_id);
/* Update the same provider (returns ref) */
provider = ai_add_provider("custom", "https://second.api.com/v1");
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
assert_non_null(provider);
assert_string_equal("https://second.api.com/v1", provider->api_url);
assert_string_equal("org2", provider->org_id);
ai_provider_unref(provider);
}
void
test_ai_add_provider_null_name_returns_null(void** state)
{
assert_null(ai_add_provider(NULL, "https://api.com/v1"));
assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL));
}
void
test_ai_add_provider_null_url_returns_null(void** state)
{
assert_null(ai_add_provider("test", NULL));
assert_null(ai_add_provider("test", NULL, NULL));
}
void
@@ -373,7 +377,7 @@ void
test_ai_providers_find_forward_custom(void** state)
{
/* Add a custom provider and test */
ai_add_provider("custom", "https://custom.api.com/v1");
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
assert_non_null(result);
@@ -483,7 +487,7 @@ test_ai_models_find_null_session(void** state)
void
test_ai_models_find_null_provider(void** state)
{
ai_add_provider("temp", "https://temp.api.com/v1");
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
AISession* session = ai_session_create("temp", "gpt-4");
assert_non_null(session);
ai_session_unref(session);
@@ -655,7 +659,7 @@ test_ai_parse_models_openai_format(void** state)
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
/* JSON response from Perplexity/OpenAI API */
const gchar* json = "{\"object\":\"list\","
@@ -691,7 +695,7 @@ 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("perplexity", "https://api.perplexity.ai/");
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/", NULL);
const gchar* json = "{\"object\":\"list\","
"\"data\":["
@@ -764,7 +768,7 @@ 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");
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
@@ -789,7 +793,7 @@ 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");
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
ai_parse_models_from_json(provider, "");
assert_null(provider->models);
@@ -809,7 +813,7 @@ 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");
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
/* NULL json should not crash */
ai_parse_models_from_json(provider, NULL);
@@ -825,7 +829,7 @@ 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");
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
const gchar* json = "{\"object\":\"list\","
"\"data\":["
@@ -849,7 +853,7 @@ 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");
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
/* JSON with spaces after colons (common in formatted JSON) */
const gchar* json = "{ \"object\": \"list\", "
@@ -1461,236 +1465,3 @@ test_ai_prefs_multiple_providers_persist(void** state)
assert_string_equal("sk-openai-key", k1);
assert_string_equal("pplx-key", k2);
}
/* ========================================================================
* Autocomplete deeper coverage
*
* Most of these exercise ai_models_find and the persistence semantics of
* ai_providers_find. The fixture build is a bit involved because
* ai_models_find takes a ProfAiWin* through which it walks
* window->session->provider->models — but ai_models_find only dereferences
* the session field, never the ProfWin base or memcheck, so a minimal
* stack-allocated struct is sufficient.
* ======================================================================== */
#include "ui/win_types.h"
/* Helper: add a list of models to a provider via the public model parser. */
static void
_seed_models(AIProvider* provider, const gchar* const* model_ids)
{
GString* body = g_string_new("{\"object\":\"list\",\"data\":[");
for (int i = 0; model_ids[i]; i++) {
if (i > 0) {
g_string_append_c(body, ',');
}
g_string_append_printf(body, "{\"id\":\"%s\",\"object\":\"model\"}", model_ids[i]);
}
g_string_append(body, "]}");
ai_parse_models_from_json(provider, body->str);
g_string_free(body, TRUE);
}
void
test_ai_models_find_no_models_returns_null(void** state)
{
/* A provider with no cached models must return NULL, not crash. */
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
assert_non_null(p);
/* p->models is NULL initially. */
AISession* s = ai_session_create("acme", "anything");
assert_non_null(s);
ProfAiWin win = { 0 };
win.session = s;
auto_gchar gchar* match = ai_models_find("m", FALSE, &win);
assert_null(match);
ai_session_unref(s);
}
void
test_ai_models_find_returns_match_for_prefix(void** state)
{
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
assert_non_null(p);
const gchar* models[] = { "gpt-4o", "gpt-4o-mini", "o1-preview", NULL };
_seed_models(p, models);
assert_int_equal(3, g_list_length(p->models));
AISession* s = ai_session_create("acme", "anything");
assert_non_null(s);
ProfAiWin win = { 0 };
win.session = s;
auto_gchar gchar* match = ai_models_find("o1", FALSE, &win);
assert_non_null(match);
assert_string_equal("o1-preview", match);
ai_session_unref(s);
}
void
test_ai_models_find_no_match_returns_null(void** state)
{
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
const gchar* models[] = { "gpt-4o", NULL };
_seed_models(p, models);
AISession* s = ai_session_create("acme", "anything");
ProfAiWin win = { 0 };
win.session = s;
auto_gchar gchar* match = ai_models_find("nothing-matches", FALSE, &win);
assert_null(match);
ai_session_unref(s);
}
void
test_ai_models_find_cycles_through_matches(void** state)
{
/*
* BUG-CATCHING TEST. ai_models_find should cycle through all matches
* that share the prefix when called repeatedly (the normal Tab/Tab/Tab
* loop). Today ai_models_find allocates a fresh Autocomplete on every
* call and frees it before returning, so the "last_found" cursor is
* never persisted. Repeated calls with the same prefix return the
* same model and the user cannot reach the others.
*
* Fix: keep the per-provider models Autocomplete static (analogous to
* the static providers_ac used by ai_providers_find).
*/
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
const gchar* models[] = { "gpt-4o", "gpt-4o-mini", "gpt-4o-nano", NULL };
_seed_models(p, models);
assert_int_equal(3, g_list_length(p->models));
AISession* s = ai_session_create("acme", "anything");
ProfAiWin win = { 0 };
win.session = s;
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* m = ai_models_find("gpt-4o", FALSE, &win);
assert_non_null(m);
assert_true(g_str_has_prefix(m, "gpt-4o"));
g_hash_table_add(seen, g_strdup(m));
}
assert_int_equal(3, g_hash_table_size(seen));
g_hash_table_destroy(seen);
ai_session_unref(s);
}
void
test_ai_models_find_previous_direction(void** state)
{
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
const gchar* models[] = { "alpha", "beta", "gamma", NULL };
_seed_models(p, models);
AISession* s = ai_session_create("acme", "anything");
ProfAiWin win = { 0 };
win.session = s;
auto_gchar gchar* forward = ai_models_find("", FALSE, &win);
auto_gchar gchar* back = ai_models_find("", TRUE, &win);
assert_non_null(forward);
assert_non_null(back);
ai_session_unref(s);
}
void
test_ai_models_find_null_search_cycles_all(void** state)
{
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
const gchar* models[] = { "alpha", "beta", "gamma", NULL };
_seed_models(p, models);
AISession* s = ai_session_create("acme", "anything");
ProfAiWin win = { 0 };
win.session = s;
/* Same cycling-broken expectation as the prefix variant. NULL/empty
* search should walk through every model. */
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* m = ai_models_find(NULL, FALSE, &win);
assert_non_null(m);
g_hash_table_add(seen, g_strdup(m));
}
assert_int_equal(3, g_hash_table_size(seen));
g_hash_table_destroy(seen);
ai_session_unref(s);
}
void
test_ai_providers_find_state_resets_on_prefix_change(void** state)
{
/* After cycling within prefix A, switching to a different prefix B
* must restart from the first match of B (not be confused by the
* leftover cursor from the previous prefix). */
ai_add_provider("alpha-one", "https://a/", NULL);
ai_add_provider("alpha-two", "https://a/", NULL);
ai_add_provider("beta-one", "https://b/", NULL);
auto_gchar gchar* a1 = ai_providers_find("alpha", FALSE, NULL);
auto_gchar gchar* a2 = ai_providers_find("alpha", FALSE, NULL);
assert_non_null(a1);
assert_non_null(a2);
assert_true(g_str_has_prefix(a1, "alpha"));
assert_true(g_str_has_prefix(a2, "alpha"));
/* Switching prefix mid-cycle. */
auto_gchar gchar* b = ai_providers_find("beta", FALSE, NULL);
assert_non_null(b);
assert_string_equal("beta-one", b);
}
void
test_ai_providers_find_empty_then_prefix(void** state)
{
/* Cycling with an empty search then narrowing to a prefix should yield
* a result starting with the prefix. */
auto_gchar gchar* any = ai_providers_find("", FALSE, NULL);
assert_non_null(any);
auto_gchar gchar* match = ai_providers_find("op", FALSE, NULL);
assert_non_null(match);
assert_true(g_str_has_prefix(match, "op"));
}
void
test_ai_providers_find_after_remove_skips_removed(void** state)
{
ai_add_provider("zalpha", "https://z/", NULL);
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);
}
void
test_ai_providers_find_after_add_includes_new(void** state)
{
/* A provider added after autocomplete state was first initialized
* must appear in subsequent completions. Catches the regression
* where the static Autocomplete is populated only at init. */
auto_gchar gchar* prime = ai_providers_find("", FALSE, NULL);
(void)prime;
ai_add_provider("xenon", "https://x/", NULL);
auto_gchar gchar* match = ai_providers_find("xen", FALSE, NULL);
assert_non_null(match);
assert_string_equal("xenon", match);
}

View File

@@ -132,15 +132,3 @@ void test_ai_parse_models_multiple_models(void** state);
void test_ai_prefs_round_trip_api_key(void** state);
void test_ai_prefs_round_trip_remove_key(void** state);
void test_ai_prefs_multiple_providers_persist(void** state);
/* Autocomplete deeper coverage */
void test_ai_models_find_no_models_returns_null(void** state);
void test_ai_models_find_returns_match_for_prefix(void** state);
void test_ai_models_find_no_match_returns_null(void** state);
void test_ai_models_find_cycles_through_matches(void** state);
void test_ai_models_find_previous_direction(void** state);
void test_ai_models_find_null_search_cycles_all(void** state);
void test_ai_providers_find_state_resets_on_prefix_change(void** state);
void test_ai_providers_find_empty_then_prefix(void** state);
void test_ai_providers_find_after_remove_skips_removed(void** state);
void test_ai_providers_find_after_add_includes_new(void** state);

View File

@@ -775,17 +775,6 @@ main(int argc, char* argv[])
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
/* Autocomplete deeper coverage */
cmocka_unit_test_setup_teardown(test_ai_models_find_no_models_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_returns_match_for_prefix, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_no_match_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_cycles_through_matches, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_previous_direction, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_null_search_cycles_all, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_state_resets_on_prefix_change, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_then_prefix, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_add_includes_new, ai_client_setup, ai_client_teardown),
// Flatfile export/import round-trip
cmocka_unit_test(test_ff_roundtrip_simple_chat),
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),