mirror of
https://git.jabber.space/devs/cproof.git
synced 2026-07-18 08:46:20 +00:00
Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom endpoints) to provide AI-assisted chat within CProof. Users can start sessions with /ai start, send prompts, receive responses in a dedicated AI window, switch between providers and models, and manage API keys — all with tab-completion. Providers are configured via /ai set commands with per-provider API keys, endpoints, default models, and custom settings. Two default providers (openai, perplexity) are seeded on first use. Provider state persists in [ai/<name>] sections of the preferences keyfile with automatic migration from the previous flat-key format. The /ai command integrates into the existing command system with 8 subcommands covering provider management, session lifecycle, model fetching, and conversation clearing. Autocomplete uses the standard flat prefix-matching chain for reliable tab-completion at every nesting level. A new ProfAiWin window type is added to the window system. Architecture: Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s) Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM JSON parsing uses unified helpers for both chat responses and error envelopes with consistent escape decoding. The response parser tries Perplexity /v1/responses "text" field first, then falls back to OpenAI "content". Error parsing extracts provider error.message from the standard envelope format. Model parsing handles multiple API response formats (OpenAI list, Perplexity, array) including edge cases. Tests include 470+ lines of unit tests covering provider management, session lifecycle, JSON parsing (multiple formats), autocomplete cycling, and error handling, plus functional tests for /ai command dispatch. A stub_ai.c module isolates unit tests from UI dependencies.
253 lines
7.5 KiB
C
253 lines
7.5 KiB
C
/*
|
|
* test_ai.c
|
|
*
|
|
* Functional tests for the /ai command surface (Tier A — no network).
|
|
*
|
|
* These tests interact with profanity through its PTY console and exercise
|
|
* paths that do not reach libcurl: command parsing, provider registration,
|
|
* key/setting/default storage, error messages, and AI window creation.
|
|
*
|
|
* Tests that require an actual provider HTTP exchange (prompt -> response,
|
|
* /ai models <provider>, HTTP error envelopes) are out of scope here and
|
|
* belong in a separate suite backed by a local HTTP stub.
|
|
*/
|
|
|
|
#include <glib.h>
|
|
#include "prof_cmocka.h"
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "proftest.h"
|
|
#include "test_ai.h"
|
|
|
|
int
|
|
ai_init_test(void** state)
|
|
{
|
|
int ret = init_prof_test(state);
|
|
if (ret != 0) {
|
|
return ret;
|
|
}
|
|
/* Connect to stabber even though AI tests don't use XMPP. Without this
|
|
* stabber's accept loop has no client to disconnect on /quit, and
|
|
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
|
|
* the server thread. */
|
|
prof_connect();
|
|
return 0;
|
|
}
|
|
|
|
void
|
|
ai_no_args_shows_help(void** state)
|
|
{
|
|
/* `/ai` with no arguments lists the built-in providers and a usage hint.
|
|
* We don't pin specific provider names — defaults may change. Verify the
|
|
* header, the "Configured providers" section, that *some* provider line
|
|
* carries an http(s) URL, and the usage hint. */
|
|
prof_input("/ai");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
|
|
assert_true(prof_output_exact("Configured providers:"));
|
|
assert_true(prof_output_regex("URL: https?://"));
|
|
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_providers_lists_defaults(void** state)
|
|
{
|
|
/* `/ai providers` (no "list") shows the built-in list with URLs. */
|
|
prof_input("/ai providers");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_exact("Available AI providers:"));
|
|
/* At least one URL line is rendered — exact name agnostic. */
|
|
assert_true(prof_output_regex("https?://"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_providers_list_shows_key_status(void** state)
|
|
{
|
|
/* `/ai providers list` lists each configured provider with key status. */
|
|
prof_input("/ai providers list");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_exact("Configured providers:"));
|
|
/* No tokens have been set yet in this fresh session. */
|
|
assert_true(prof_output_regex("Key: NOT configured"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_provider_adds_custom(void** state)
|
|
{
|
|
/* Adding a custom provider should make it appear in /ai providers list. */
|
|
prof_input("/ai set provider mock http://127.0.0.1:1/");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'mock' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai providers list");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("mock"));
|
|
assert_true(prof_output_regex("http://127.0.0.1:1/"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_token_marks_key_set(void** state)
|
|
{
|
|
/* Use a self-set-up provider so the test doesn't pin a default name. */
|
|
prof_input("/ai set provider testprov https://example.test/");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai set token testprov sk-fake-test-key");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("API token set for provider: testprov"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai providers list");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Key: configured"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_start_unknown_provider_errors(void** state)
|
|
{
|
|
/* Unknown provider name should error out without creating a window. */
|
|
prof_input("/ai start nope_provider somemodel");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_start_without_key_errors(void** state)
|
|
{
|
|
/* Self-set-up provider with no token. /ai start must refuse. */
|
|
prof_input("/ai set provider testprov https://example.test/");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai start testprov model-x");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("No API key set for provider 'testprov'"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_start_with_key_opens_window(void** state)
|
|
{
|
|
/* Self-set-up provider with token; /ai start opens a WIN_AI window. */
|
|
prof_input("/ai set provider testprov https://example.test/");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai set token testprov sk-fake-test-key");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("API token set for provider: testprov"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai start testprov model-x");
|
|
|
|
prof_timeout(5);
|
|
/* /ai start switches focus to the new WIN_AI window; the window prints
|
|
* "AI Chat: <provider>/<model>" as its first line. */
|
|
assert_true(prof_output_regex("AI Chat: testprov/model-x"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_clear_without_window_errors(void** state)
|
|
{
|
|
/* /ai clear from the console (no active AI window) refuses with the
|
|
* shared "must be in AI chat window" guard used by /ai switch as well. */
|
|
prof_input("/ai clear");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Must be in an AI chat window"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_remove_provider_works(void** state)
|
|
{
|
|
/* Round-trip: add -> verify present -> remove -> verify gone. */
|
|
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai remove provider tmpprov");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai remove provider tmpprov");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_remove_provider_unknown_errors(void** state)
|
|
{
|
|
/* Removing a provider that was never added must report "not found". */
|
|
prof_input("/ai remove provider nonexistent_xyz");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_default_model_updates_provider(void** state)
|
|
{
|
|
/* Setting a default model is acknowledged on the console. */
|
|
prof_input("/ai set provider testprov https://example.test/");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai set default-model testprov model-preview");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Default model for provider 'testprov' set to: model-preview"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_switch_without_window_errors(void** state)
|
|
{
|
|
/* /ai switch with no active AI window refuses with the shared
|
|
* "must be in AI chat window" guard. */
|
|
prof_input("/ai switch testprov model-x");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Must be in an AI chat window"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_bad_subcommand_shows_usage(void** state)
|
|
{
|
|
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
|
|
prof_input("/ai not_a_subcommand");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
|
|
prof_timeout_reset();
|
|
}
|