[#110] Add AI client with multi-provider support and UI #113
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/ai"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Introduced change
An AI client module that integrates with OpenAI-compatible API providers to deliver AI-assisted responses within the CProof chat client. Users can create AI sessions, send prompts, and view responses in a dedicated AI window — all from within the terminal UI.
Capabilities
/ai provider addgpt-4,sonar)Reasoning behind the change
CProof is an XMPP client focused on privacy and usability. Adding AI support gives users a way to get quick answers, message drafting assistance, or knowledge retrieval without leaving the chat client.
The design prioritizes:
AIProviderstruct with name, URL, org_id) makes it trivial to add new providers.Implementation details
Architecture Overview
Key Implementation Details
Provider Management — Providers are stored in a
GHashTablekeyed by name. Each provider has a reference count for safe shared ownership:Default providers are registered during
ai_client_init():openaihttps://api.openai.com/v1/responsesperplexityhttps://api.perplexity.ai/v1/responsesSession Lifecycle — Sessions track conversation history and are reference-counted:
Async HTTP Request
ai_send_prompt()spawns a GThread that:Response size is capped at 10MB to prevent OOM conditions.
UI Integration
A new
ProfAiWinwindow type displays AI conversations. Responses are streamed line-by-line; errors are displayed in red.Testing
Unit tests cover:
Resolves #110
Overview
Adds an AI client module with async HTTP requests via libcurl, a
WIN_AIwindow, and an/aicommand (with subcommandsset/remove/start/clear/correct/providers), plus per-provider API key storage in.profrc. The architectural idea is reasonable (providers in aGHashTable, ref-counted sessions, dedicated thread for HTTP), but the implementation is raw — many bugs at the level of correctness, resources, and tests.🔴 Critical defects
1. Tests reference URLs that don't exist in the code — CI is red
tests/unittests/test_ai_client.c:34-39 asserts:
But src/ai/ai_client.c:25-26 defines:
test_ai_client_initis guaranteed to fail. The tests clearly weren't run.2. Default provider URLs are non-functional
https://api.perplexity.ai/v1/responsesdoes not exist — Perplexity useshttps://api.perplexity.ai/chat/completionswith amessagespayload._build_json_payloadsends{"model":...,"input":[...],"stream":false}— that's the OpenAI/v1/responsesformat. Any OpenAI-compatible server (LiteLLM, Ollama, vLLM, llama.cpp, local OpenAI emulator) expectsmessageson/v1/chat/completions— the feature advertised in the PR description ("local OpenAI-compatible servers are also supported") doesn't work.3. Assistant message is added to history twice
In src/ai/ai_client.c:772 the worker calls
ai_session_add_message(session, "assistant", content);, then immediately invokesaiwin_display_response(aiwin, content)(src/ai/ai_client.c:778), and that function in src/ui/window.c:2470 again doesai_session_add_message(win->session, "assistant", response);. After every response the assistant history is duplicated → the next request sends the duplicates to the API → context and token counting break.4. API keys aren't persisted to disk
src/config/preferences.c:1714-1726
prefs_ai_set_token/prefs_ai_remove_tokenmutateprefsin memory but never call_save_prefs(). Compare withprefs_set_stringin the same file. After a client restart all keys are lost, even thoughai_load_keys()tries to read them — it will always get nothing. The advertised "API keys are stored per-provider in preferences" doesn't actually work.5. API key leaked into the debug log
src/ai/ai_client.c:718:
sk-...plus a random part, Perplexity withpplx-.... Secret leakage into logs — CWE-532.g_strndup(...)return value is never freed — memory leak per request.This line must go entirely.
6. Double
g_strdup→ leaksrc/ai/ai_client.c:425:
ai_get_provider_keyalready returns a freshg_strdup(see ai_client.c:379). The outerg_strdupcopies again, the inner result is lost. Leak per session creation. Drop the outerg_strdup.7.
ai_list_providerscontract contradicts implementationThe header advertises:
But src/ai/ai_client.c:325
ai_provider_ref(...)s every provider, and the list needsg_list_freeplusai_provider_unrefon every element. Some callers do this correctly (cmd_ai_providers), some only callg_list_free— for examplecmd_ai, which leaks references on every parameter-less/ai. Either the header doc or the implementation needs to change, and all call sites need to be aligned.8.
cmd_ai_start:provider_nameleak +const-castsrc/command/cmd_funcs.c (
cmd_ai_start):g_strndupreturnsgchar*, gets assigned toconst gchar*(warning), and is never freed. Leak per/ai start name/model. Useauto_gchar gchar*and a temporary.9. Command arguments are split on whitespace
/ai correct hello worldthroughparse_argsyieldsargs[1]="hello",args[2]="world"—cmd_ai_correctonly takesargs[1]. Same issue for/ai set token <provider> <secret with spaces>(though secrets usually don't contain spaces) and potentially/ai start. Needs a parsing form with a freetext tail (likecmd_msg/cmd_correct) or manualargs[1..N]joining.10.
ai_providers_find— broken tab-cyclesrc/ai/ai_client.c:336-371:
g_hash_table_iter_*returns elements in non-deterministic order. Tab completion needs to cycle through matches — the current implementation always returnsg_list_lastwhenprevious=FALSEandmatches->datawhenTRUE, with no state between keypresses, so there's no cycle through the list. The tests don't catch this because every test case has only a single match (o→openai,p→perplexity). Compare with other*_findimplementations in the project — they use a statefulAutocomplete. Better: keep anAutocomplete* providers_ac, sync it with the hash table onadd/remove, and don't reinventfindby hand.🟠 Serious issues
11.
_ai_invoke_callbacklies about being on the UI threadThe comment at src/ai/ai_client.c:142: "profanity uses ncurses, not GLib main loop, so call directly". The callback runs on the worker, while the PR description promises "responses and errors invoke callbacks on the main UI thread". For now
aiwin_display_response/aiwin_display_errorare saved only by thepthread_mutex_lock(&lock)taken in the worker (see #14), but the callback machinery (ai_callback_data_t) is also set up, partially used (no-key path, curl init fail), and at the same time invokescb_data->error_cbwithout holdinglock— asymmetric. Either really proxy viag_idle_add(but profanity doesn't run a GMainLoop), or guarantee that any code in the callback takeslock. As-is, some paths take it and some don't — a bug waiting to happen.12. HTTP errors and parse errors fully ignore
error_cbIn _ai_request_thread at
http_code >= 400or parse failure, error_cb is never invoked — the function callsaiwin_display_error(aiwin, ...)directly viauser_data. So:ai_send_promptcontract is broken —error_cbis promised but doesn't fire on typical errors.ai_send_prompt(e.g. a script integration, correction viacmd_ai_correct) won't see errors.user_datarigidly assumes it's aProfAiWin*— the "callbacks" layer is fictitious.Either drop the illusion (remove
ai_response_cb/ai_error_cb, acceptProfAiWin*explicitly), or honestly proxy all errors throughcb.13. Lifetime of
ProfAiWin* aiwin = (ProfAiWin*)user_data— TOCTOUThe worker holds an
ai_session_refbut does not hold a reference to the window. If the user closes the AI window during the 60-second libcurl timeout,win_freeruns on the main thread,aiwinbecomes dangling in the worker, and theaiwin->memcheck == PROFAIWIN_MEMCHECKcheck passes accidentally (the memory may be reused). Either introduce a refcount forProfAiWin, or look upwins_get_ai()underlockand compare pointers. As-is — a potential crash/UAF.14. Refcount isn't atomic
src/ai/ai_client.c:200
provider->ref_count++/--and src/ai/ai_client.c:437session->ref_count++/--are plain operations. The session is passed to a worker thread (ai_session_refon the main thread,ai_session_unrefin the worker). That's a data race. Useg_atomic_int_inc/g_atomic_int_dec_and_testorgatomicrefcount.15. The hand-rolled JSON parser is unreliable
_parse_ai_response searches for
"text":"..."viastrstr. Problems:strstrwill find"text"anywhere, including inside another field's value or in a streaming chunk.\"escape; doesn't handle\\,\/,\n,\t,", etc. — real provider responses contain\ninsidecontent, and they reach the user as literal\n."content":"..."will match even an echo of the user message in some response shapes ({"choices":[{"message":{"content":...}}],"prompt":[{"content":...}]}— the first occurrence wins).The project already pulls in libstrophe (XML), but for JSON it's time to add json-glib or use a parser from the libcurl standard environment. Otherwise any edge-case responses (with errors, with reasoning blocks, with tool-calls) will misparse.
16. Response size:
realsizekeeps incrementing after truncatesrc/ai/ai_client.c:34-37:
Returning
realsizewithout writing means "consumed successfully", and curl keeps streaming data right up to the 60-second timeout. That's not truncation, that's a silent drop + wasted bandwidth. Better to return0/CURL_WRITEFUNC_ERRORso curl aborts the transfer.17. Command
/ai model <model>is documented but not implementedIn cmd_defs.c
CMD_SYN/CMD_ARGSincludes/ai model <model>, butCMD_SUBFUNCSdoesn't. The command silently falls intocons_bad_cmd_usage. Same with the discrepancy between/ai providersand/ai providers-list(hyphenated in the syntax,providersinCMD_SUBFUNCS, andcmd_ai_providersexpectsargs[1] == "list").18.
wins_get_aireturns the first window —cmd_ai_clear/cmd_ai_correctbreak with ≥2 sessionswins_new_ailets multiple AI windows be opened.wins_get_aireturns whatever it finds first. If the user is in the second window and runs/ai correct ...— the edit lands in the wrong session. Either restrict to a single AI window, or use the current window.19.
wins_close_ac, "ai"is added on every creationsrc/ui/window_list.c:728
autocomplete_add(wins_ac, "ai")/autocomplete_add(wins_close_ac, "ai")runs every timewins_new_aiis called. IfAutocompletedoesn't deduplicate, you'll get duplicates in/wins close <Tab>.🟡 Other notes
Code quality / style
log_debug("[AI-THREAD] ..."),[AI-WIN],[AI-CMD],[AI-CALLBACK],[AI-PROMPT]ENTER/EXIT lines. This is debugging code that should disappear before merge — it bloats production logs and gets in the way when debugging real issues.#include "ai/ai_client.h"in the middle of src/ui/window.h:104 — after the prototypes, before#endif. Breaks include order, andwindow.h(widely included) drags all AI dependencies along. Use the forward declarationtypedef struct ai_session_t AISession;(already present inwin_types.h).ci-build.shchanges are unrelated to the feature — Pikaur simulation already landed in0feacbc9d. Looks like a rebase mistake or a stray commit. Drop from this PR.preferences.cmissing trailing newline (diff shows\ No newline at end of file). Style nit that breaks git patches.PREF_AI_DEFAULT_MODELis added to the enum but has no_get_group,_get_key, or_get_default_stringmapping — dead code or a future bug.cons_show(" %s (URL: %s, Key: %s)", ..., key ? "set" : "not set");incmd_ai—keyisgchar*(typed asconst gchar*, which is wrong:ai_get_provider_keyreturns an owned pointer that must beg_free'd). Leak plus incorrect const.args[5] = NULL; /* placeholder for built payload */in ai_send_prompt — unused slot.g_list_appendinai_session_add_messageand in_build_json_payloadis O(n²). For long histories — a noticeable slowdown. Replace withGQueueor tail-tracking append.Security
~/.local/share/profanity/profrc— the storage mode isn't mentioned in the PR description even though "Privacy" is promised. At minimum, warn the user via/help aiand in the log on/ai set token. Better — support reading from an environment variable or from a file pointed to by a preference (as many CLIs do)./ai set token openai sk-...command ends up in the profanity command history. Maybe exclude it from history, or accept the token interactively (no echo) or from a file.Tests
prefs_ai_*functions, the autocomplete cycle with >1 match, or error handling.test_ai_remove_provider's comment "Remove default provider should fail" doesn't match the test (assert_false(ai_remove_provider("nonexistent"))tests a nonexistent one, not a default).test_ai_session_ref_unrefaccessesrefvia equality inassert_trueafterunref, but doesn't verify the free actually happened — needsvalgrind/--track-origins.Bottom line
The PR is in draft/WIP status, and it's clearly WIP: tests fail (see #1), the flagship feature (Perplexity, local OAI servers) doesn't work (#2), API keys are lost on restart (#4), assistant message double-add (#3), memory leaks and key leakage in logs (#5–8). These are blockers for merge.
Recommendation: at minimum address #1–10 (correctness + security) before re-review. In parallel, consider:
ci-build.shchanges into a separate PR.[AI-*]debug logging.wins_get_ai/the commands accordingly.The URL typo (
/v1/responsesvs/v1/chat/completions) hints that the API format wasn't tested end-to-end at all — worth doing that before the next review iteration.Bug: after multiple messages in conversation with AI, message from user is not being displayed.
b3d0eec0bctoe229ed1281Round 1 — fixes verified
The author addressed most blockers from the previous pass. Spot-checked at HEAD
f4221e27a:bccd3ecdeaiwin_display_responseno longer touchessession->history25e045997_save_prefs()called fromprefs_ai_set_token/_remove_tokenc9a523911g_strduponsession->api_keycaa0b3ccbai_list_providerscontract mismatch93ad7379ecmd_ai_startprovider_nameleak9e1f9b666auto_gchar gchar* owned_provider_name/ai correctda0bf43d7g_strjoinv(" ", &args[1])ai_providers_findnon-cycling tab-complete461c0c32dAutocomplete634fb7d7e,cead417e7_aiwin_display_*helpers that takelockcead417e7ProfAiWin*from worker2fc7f3d67_aiwin_validateconsultswins_ai_exists(...)while holdinglockf133d81a0g_atomic_int_inc/g_atomic_int_dec_and_testpreferences.cmissing trailing newlinecdcc45b7ccmd_aikeyleak in plaincons_showauto_gchar gchar* key🟠 Still outstanding
F2 — Default URLs still don't reach a working endpoint for two of three claimed targets
src/ai/ai_client.c:27-28:
…and at request time src/ai/ai_client.c:730:
The path
v1/responsesis hardcoded for every provider:/v1/responses. Real endpoint ishttps://api.perplexity.ai/chat/completions. The shipped default for Perplexity will return 404 on the first prompt./v1/chat/completions. The PR description still claims "local OpenAI-compatible servers are also supported" — that claim does not hold.A custom provider can override the base URL via
/ai set provider, but cannot switch the path scheme or payload shape ({"input":[...],"store":false}vs{"messages":[...]}). Either:api_url/org_id), default it appropriately foropenai/perplexity, or/v1/chat/completionswithmessages) and pick by provider.F15 — JSON parser improved but still not robust
src/ai/ai_client.c:608-664 now decodes
\nin addition to\"(a16237d16). Remaining gaps:\\,\t,\r,\f,\b,\/,\uXXXXare passed through verbatim — JSON decoders will produce wrong text whenever the model emits Unicode or tab/CR characters.strstr("\"text\":\"")/strstr("\"content\":\"")heuristic still matches anywhere in the body, including key fragments inside other field values. For error envelopes like{"error":{"message":"...","type":"..."},"text":"..."}it can pick the wrong field.Recommendation unchanged: pull in
json-glib(already an optional dep on most distros that ship glib) and write three or four lines for the happy path. The current code is one weird response away from a bad bug.F16 —
_write_callbackoverflow path still silently consumes datasrc/ai/ai_client.c:51-53:
Returning
realsizemakes libcurl believe the chunk was written, so it will keep streaming the remaining body up to the 60s timeout. This is "drop and waste bandwidth", not "truncate". Return0(orCURL_WRITEFUNC_ERROR) so curl aborts the transfer; the existing partial buffer is what_parse_ai_responsewill see.F17 — Documented commands without implementations
src/command/cmd_defs.c:2793-2824 still advertises:
/ai providers-list— noproviders-listsubcommand exists;cmd_ai_providerslooks forargs[1] == "list"(i.e./ai providers list). Help text and behavior diverge./ai model <model>— nocmd_ai_modelmapped inCMD_SUBFUNCS. Invocation falls through tocons_bad_cmd_usage.Either implement them or remove from
CMD_SYN/CMD_ARGS/CMD_EXAMPLES.F18 —
wins_get_aistill returns the first AI windowsrc/ui/window_list.c:870-885 iterates
valuesand returns on the firstWIN_AI.wins_new_aidoes not prevent multiple AI windows. So:/ai correct ...always edits the first window's session, even when the user is typing in window N./ai clear(when invoked from a non-AI window) clears only the first one.Either restrict to a single AI window (assert in
wins_new_ai), or look up the current window and fall back towins_get_ai()only if not inWIN_AI.cmd_ai_clearalready does the right thing for the in-window case;cmd_ai_correctdoes not.Bundled unrelated change —
ci-build.shci-build.sh diff still contains the Pikaur parity block. The same change already shipped on
masterin0feacbc9d. This is duplicate / rebase residue and should be dropped from the PR.PREF_AI_DEFAULT_MODELstill unmappedsrc/config/preferences.h:186 declares
PREF_AI_DEFAULT_MODELbut_get_group,_get_keyand_get_default_stringonly handlePREF_AI_PROVIDER/PREF_AI_API_KEY. The enum is dead — either wire it up (default model setting feels useful) or delete the symbol.Debug log noise
grep -c '\[AI-' src/ai/ai_client.c src/ui/aiwin.cshows ~28log_debugcalls tagged[AI-THREAD]/[AI-WIN]/[AI-PROMPT]/[AI-CMD]remain.4913a3d5aremoved the inaccurate ones, but most of the ENTER/EXIT and parameter-dump lines are still in. They will be on atDEBUGlog level in production. Consider gating behind a feature-specific level or stripping pre-merge.🆕 New findings introduced by the round-1 follow-ups
N1 —
_aiwin_validatereads the globalvalueslist without holdinglocksrc/ai/ai_client.c:80-94:
wins_ai_existswalks the globalvaluesGList*(the window list) — that list was previously only ever touched from the main thread and is not protected by any synchronization. After this PR the worker thread reads it from_aiwin_display_response/_aiwin_display_errorbefore takingpthread_mutex_lock(&lock). Concurrent mutation from the main thread (window create/close shufflesvalues) races with the worker'sg_list_nextwalk; a list node can beg_free'd mid-traversal.Fix: pull the existence check inside the locked section so it runs under the same mutex that guards the dispatch:
N2 — TOCTOU between
_aiwin_validatereturning andpthread_mutex_lockacquiringEven if N1 is addressed for
valuesaccess, the validate→lock→display sequence has a window where the main thread can runwin_freeon the window between steps.aiwin_display_responsethen assertsmemcheckon memory that's been freed — may pass accidentally, may crash. Same fix as N1: validate inside the critical section, not before it.N3 —
OpenAI-Organizationheader sent to non-OpenAI providerssrc/ai/ai_client.c:712-715:
The header name is hardcoded to
OpenAI-Organizationand is sent to every provider that hasorg_idset — Perplexity, Anthropic, Together, OpenRouter, custom gateways. Most don't recognize it (harmless), some strict gateways reject the request as 400. Either store the header name per-provider, or only emit it whenprovider_name == "openai".N4 —
cl_ev_send_ai_msg(..., id)takes anidthat's alwaysNULLsrc/event/client_events.c:289-309 declares the parameter; the only caller src/command/cmd_funcs.c:8379 passes
NULLbecause AI messages have no XMPP id source. Either drop the parameter, or actually generate a ULID/UUID for/ai correct-style flows where an id would let us locate and replace the message in the buffer. As-is, dead parameter in a public API.N5 — Orphaned session leak when window is closed mid-flight
The worker holds an
ai_session_ref(session), so the session outlives the window — that part is correct. But if the user closes the AI window betweenai_send_promptand the response arrival, the worker:session->history(src/ai/ai_client.c:769) — before dispatch._aiwin_display_response, which fails the validate and drops the message on the floor.ai_session_unref(session)at thread exit — refcount drops to 0, session is freed.So the message is silently lost (no error shown), and no follow-up "show in next AI window" hook exists because
wins_new_aialways creates a freshAISession. Not a leak (refcount semantics are right), but a UX hole: the user closes the window, opens a new one to the same provider, and their previous in-flight reply has vanished without any "request was cancelled" notice. Either show a console notice on validate-fail, or drop the in-flight history append until the response is actually displayed.Note:
ai_client_shutdownis still never called from profanity's main shutdown path (also a round-1 carryover) —curl_global_cleanupand the providers/keys hash tables leak at exit. Easy fix: call fromprof_shutdown.🟡 Not addressed (lower priority but worth noting)
~/.local/share/profanity/profrc— works as designed but the PR description still markets "Privacy" without disclosure. Add acons_showwarning on first/ai set token, or document mode of storage in/help ai. Better: support${VAR}-style env-var indirection or a separate file path so users can keep keys out of dotfile backups./ai set token openai sk-...lands in profanity command history — easy footgun. Other clients (e.g.psql) suppress\passwordfrom history; profanity would benefit from the same for any command that takes a secret.prefs_ai_*round-trip, or autocomplete cycling with ≥2 matches. The default-URL test passes only because there are zero mismatches; behavior under realistic responses is unverified.g_list_appendinai_session_add_messageand_build_json_payloadis O(n²) — long histories will slow noticeably. Switch toGQueueor track the tail.Bottom line
Round 1 was a substantive fix-up — 13 of the 14 critical/serious findings are resolved, and the threading / lifetime story (F11–14) is now coherent at the high level. The remaining blockers cluster around:
_aiwin_validatehelper added in round 1 introduces a fresh data race againstvaluesand a TOCTOU before locking. Same class of UAF the original F13 was supposed to close."away from corrupt output.wins_get_aistill single-window-only.Once F2, N1/N2 and the doc/impl mismatches in F17 are closed, the PR can ship; F15/F16/F18 can land as small follow-ups but should ideally be settled before exiting WIP. The unrelated
ci-build.shblock should be removed from this PR regardless.Round 1 — fixes verified
Spot-checked at HEAD
f4221e27a:bccd3ecdeaiwin_display_responseno longer touchessession->history25e045997_save_prefs()called fromprefs_ai_set_token/_remove_tokenc9a523911g_strduponsession->api_keycaa0b3ccbai_list_providerscontract mismatch93ad7379ecmd_ai_startprovider_nameleak9e1f9b666auto_gchar gchar* owned_provider_name/ai correctda0bf43d7g_strjoinv(" ", &args[1])ai_providers_findnon-cycling tab-complete461c0c32dAutocomplete634fb7d7e,cead417e7_aiwin_display_*helpers that takelockcead417e7ProfAiWin*from worker2fc7f3d67_aiwin_validateconsultswins_ai_exists(...)while holdinglockf133d81a0g_atomic_int_inc/g_atomic_int_dec_and_testpreferences.cmissing trailing newlinecdcc45b7ccmd_aikeyleak in plaincons_showauto_gchar gchar* keyRound 2 — fixes verified
_write_callbackreturnedrealsizeon overflow (silent drop)e229ed128CURL_WRITEFUNC_ERROR, curl aborts transfer/ai modeland/ai providers-listdocumented but not implementedacae54305/ai switchand/ai modelswins_get_aiignores current windowacae54305cmd_ai_switch,cmd_ai_clear,cmd_ai_modelsnow do(window && window->type==WIN_AI) ? (ProfAiWin*)window : wins_get_ai()wins_ai_existsread withoutlock07d267b5block'decb07fd00cons_showinstead of droppinga898cb212_parse_openai_models); chat content (_parse_ai_response) is still strstr-based with only\"/\nescapes07d267b5b🟠 Still outstanding
F2 — Default URLs still don't reach a working endpoint for two of three claimed targets
src/ai/ai_client.c:27-28:
…and at request time src/ai/ai_client.c:730:
The path
v1/responsesis hardcoded for every provider:/v1/responses. Real endpoint ishttps://api.perplexity.ai/chat/completions. The shipped default for Perplexity will return 404 on the first prompt./v1/chat/completions. The PR description still claims "local OpenAI-compatible servers are also supported" — that claim does not hold.A custom provider can override the base URL via
/ai set provider, but cannot switch the path scheme or payload shape ({"input":[...],"store":false}vs{"messages":[...]}). Either:api_url/org_id), default it appropriately foropenai/perplexity, or/v1/chat/completionswithmessages) and pick by provider.F15 (chat path) — Chat response parser still strstr-based
a898cb212added a robust parser for_parse_openai_models(models list endpoint) with proper whitespace/escape handling. But the chat-response path src/ai/ai_client.c:1200-1280 — which is what every user prompt hits — is unchanged from round 2:strstr("\"text\":\"")andstrstr("\"content\":\"")heuristics that match anywhere in the body.\"and\n.\\,\t,\r,",\b,\f,\/are passed through verbatim._parse_error_response(new inecb07fd00) adds\\and\thandling but is also strstr-based.Now there are three hand-rolled "JSON parsers" in one file with slightly different escape coverage. Worth either porting the robust parser from
_parse_openai_modelsto the chat path, or just pulling injson-glibfor all three.Bundled unrelated change —
ci-build.shci-build.sh diff still contains the Pikaur parity block. The same change already shipped on
masterin0feacbc9d. This is duplicate / rebase residue and should be dropped from the PR.PREF_AI_DEFAULT_MODELstill unmappedsrc/config/preferences.h:186 declares
PREF_AI_DEFAULT_MODELbut_get_group,_get_keyand_get_default_stringonly handlePREF_AI_PROVIDER/PREF_AI_API_KEY. The enum is dead — either wire it up (default model setting feels useful) or delete the symbol.Debug log noise
grep -c '\[AI-' src/ai/ai_client.c src/ui/aiwin.cshows ~28log_debugcalls tagged[AI-THREAD]/[AI-WIN]/[AI-PROMPT]/[AI-CMD]remain.4913a3d5aremoved the inaccurate ones, but most of the ENTER/EXIT and parameter-dump lines are still in. They will be on atDEBUGlog level in production. Consider gating behind a feature-specific level or stripping pre-merge.🆕 New findings introduced by the round-1 follow-ups
N2 — TOCTOU between
_aiwin_validateand_aiwin_display_*still open (round 2 fix was incomplete)07d267b5bwrapped thewins_ai_existscheck in lock/unlock — that fixes the data race onvaluestraversal (N1, closed). But the calling pattern in src/ai/ai_client.c:110-148 still releaseslockbetween the existence check and the actual dispatch:Between the unlock at line 95 and the re-lock at line 145, the main thread can complete
win_free. Thememcheckassert insideaiwin_display_responsethen runs on freed memory — usually crashes, sometimes passes by coincidence. Same UAF class as the original F13.Correct fix: collapse validate + dispatch into one critical section:
N3 —
OpenAI-Organizationheader sent to non-OpenAI providerssrc/ai/ai_client.c:712-715:
The header name is hardcoded to
OpenAI-Organizationand is sent to every provider that hasorg_idset — Perplexity, Anthropic, Together, OpenRouter, custom gateways. Most don't recognize it (harmless), some strict gateways reject the request as 400. Either store the header name per-provider, or only emit it whenprovider_name == "openai".N4 —
cl_ev_send_ai_msg(..., id)takes anidthat's alwaysNULLsrc/event/client_events.c:289-309 declares the parameter; the only caller src/command/cmd_funcs.c:8379 passes
NULLbecause AI messages have no XMPP id source. Either drop the parameter, or actually generate a ULID/UUID for/ai correct-style flows where an id would let us locate and replace the message in the buffer. As-is, dead parameter in a public API.N5 — Orphaned session leak when window is closed mid-flight
The worker holds an
ai_session_ref(session), so the session outlives the window — that part is correct. But if the user closes the AI window betweenai_send_promptand the response arrival, the worker:session->history(src/ai/ai_client.c:769) — before dispatch._aiwin_display_response, which fails the validate and drops the message on the floor.ai_session_unref(session)at thread exit — refcount drops to 0, session is freed.So the message is silently lost (no error shown), and no follow-up "show in next AI window" hook exists because
wins_new_aialways creates a freshAISession. Not a leak (refcount semantics are right), but a UX hole: the user closes the window, opens a new one to the same provider, and their previous in-flight reply has vanished without any "request was cancelled" notice. Either show a console notice on validate-fail, or drop the in-flight history append until the response is actually displayed.Note:
ai_client_shutdownis still never called from profanity's main shutdown path (also a round-1 carryover) —curl_global_cleanupand the providers/keys hash tables leak at exit. Easy fix: call fromprof_shutdown.🆕 New findings introduced by the round-2 follow-ups
X1 —
cmd_ai_switchref-count increment is non-atomic (regresses F14)src/command/cmd_funcs.c:11118:
F14 made all session/provider refcount mutations atomic (
g_atomic_int_inc/g_atomic_int_dec_and_test). This new call site uses a plain++. Since the worker thread can be touchingprovider->ref_countviaai_provider_unrefat any moment, this is a data race / lost-decrement bug. Replace withg_atomic_int_inc(&aiwin->session->provider->ref_count);— or better, use the existingai_provider_ref()helper for consistency.X2 —
cmd_ai_switch/cmd_ai_clear/ (dead)cmd_ai_correctmutate session shared with worker without coordinationcmd_ai_switchruns on the main thread and does, unprotected:While an in-flight worker for the same session is reading exactly these fields in
_ai_request_thread(session->provider_name,session->provider->api_url,session->model,session->api_key). Race + UAF: the worker can dereference a freedsession->modelbetween theg_freeand theg_strdup. Also droppingsession->providerwhile the worker holds a borrowed pointer to it insidesession->provider->api_urlcauses UAF as soon as refcount hits zero.The same pattern applies to
ai_session_clear_history(called fromcmd_ai_clear) — it iterates and freessession->historywhile_build_json_payloadwalks it from the worker.Fix: either require all session mutations to happen under
&lockand have the workerpthread_mutex_lock(&lock)while reading session state into local copies, or implement a "request in flight" guard that refuses switch/clear until the worker completes.X3 —
cmd_ai_correctis dead codeacae54305removedcorrectfromCMD_SUBFUNCS(replaced byswitch), but the function body still lives at src/command/cmd_funcs.c:11197-11246 and the declaration incmd_funcs.his still exported. Either restore the wiring (and update help text), or delete the function + declaration. As-is, dead code with a stale'/ai start <provider>/<model>'message in its error path that no longer matches the new syntax.X4 —
_ai_generic_request_threadholds an unrefed borrow on the providersrc/ai/ai_client.c:632:
This is the models-fetch worker. It:
lock. If the main thread runs/ai remove provider Xconcurrently,g_hash_table_lookupraces withg_hash_table_remove. Profanity's main thread does not hold any lock when touchingproviders, so this is unsynchronized.AIProvider*for the duration of_curl_exec_and_handle(which callscurl_easy_perform, up to 30s). If the provider gets removed during that window, the hash table's destroy-notify callsai_provider_unrefand the provider may be freed before the worker is done. UAF onprovider->name,provider->org_id, etc.Fix:
ai_provider_ref()immediately after lookup (under whatever lock you choose for the providers table), andai_provider_unref()at thread exit. Same treatment for any other worker that consumes a borrowedAIProvider*.X5 —
org_idis now write-onlyacae54305deleted/ai set orgfromcmd_defsand fromcmd_ai_set. ButAIProvider->org_idis still allocated inai_provider_new, freed inai_provider_unref, and read by_build_curl_headersto emit theOpenAI-Organizationheader. There is no way for a user to populate it through the command surface. Either restore/ai set org(or fold into/ai set custom <provider> org <id>), or drop the field entirely along with theOpenAI-Organizationheader logic.X6 — Models-fetch error path doesn't reuse
_parse_error_response_curl_exec_and_handle(src/ai/ai_client.c:597-626) for the models endpoint formatsHTTP %ld: %sfrom the rawresponse->data. The chat path was upgraded inecb07fd00to call_parse_error_responsefirst and show the extractederror.message. Inconsistent UX: a 401 from/v1/modelsshows the raw JSON envelope, the same 401 from/v1/responsesshows the human-readable message. Easy fix — same call.N3, N4 — still open
Round 2 findings unchanged at round 3:
OpenAI-Organizationis still emitted to every provider that hasorg_idset (now via the new_build_curl_headershelper at src/ai/ai_client.c:585-588).cl_ev_send_ai_msg(..., id)still takes anidparameter that the only caller passes asNULL.🟡 Not addressed (lower priority but worth noting)
~/.local/share/profanity/profrc— works as designed but the PR description still markets "Privacy" without disclosure. Add acons_showwarning on first/ai set token, or document mode of storage in/help ai. Better: support${VAR}-style env-var indirection or a separate file path so users can keep keys out of dotfile backups./ai set token openai sk-...lands in profanity command history — easy footgun. Other clients (e.g.psql) suppress\passwordfrom history; profanity would benefit from the same for any command that takes a secret.prefs_ai_*round-trip, or autocomplete cycling with ≥2 matches. The default-URL test passes only because there are zero mismatches; behavior under realistic responses is unverified.g_list_appendinai_session_add_messageand_build_json_payloadis O(n²) — long histories will slow noticeably. Switch toGQueueor track the tail.Bottom line
Round 2 closed F16, F17, F18, N1, and N5 cleanly. Round 3 adds real features (model caching,
/ai switch,/ai models, default provider/model). But the threading discipline regressed: the same lifetime / cross-thread problems that F11–14 and N1/N2 tried to fix have resurfaced in the new code paths.Round 3 blockers:
wins_ai_existsonly closed half the issue.cmd_ai_switchregresses F14.cmd_ai_switch/cmd_ai_clearmutate the live session from the main thread while the worker reads from it. UAF/data race on every concurrent switch+request.AIProvider*for up to 30s, no lock on the providers table.v1/responsespath).Carryovers from round 2 (still relevant):
acae54305shipped a robust parser for the models endpoint.OpenAI-Organizationstill leaks to all providers.idparameter oncl_ev_send_ai_msg.cmd_ai_correctis now dead code.org_idwrite-only (no command sets it).ci-build.shblock still bundled.The PR is close to ready on feature scope but the cross-thread story needs another pass. The single safest move would be: one mutex covers all session/provider state, every main-thread
cmd_ai_*mutator takes it, the worker takes it for any read of session/provider, and_aiwin_display_*does validate + dispatch as one atomic section under&lock. Half of the round-2/3 fixes are already locks; consolidating them into a coherent policy fixes N2/X2/X4 together.AI feature — findings from writing unit + functional tests
test/ai-coverage
e67ae4e7a— test(ai): expand unit and functional coverage for /ai featureCompanion to REVIEW_PR_113.md. Findings below were surfaced while building the test suite for PR 113 (
feat/aiHEAD010b2062a), not by reading the diff. Test counts at the time of this report:tests/unittests/test_ai_client.c)The new tests live in:
/aicommand surface (Tier A).To expose two internal parsers I dropped
staticfrom_parse_ai_response→ai_parse_responseand_parse_error_response→ai_parse_error_message. Header section in src/ai/ai_client.h labels them "exposed for testing".🔴 Production correctness bugs surfaced by the tests
T1 — Chat response parser breaks on any whitespace after the JSON colon
This is the smoking-gun confirmation of finding F15 from the PR 113 review.
src/ai/ai_client.c:1207, 1241 and :1297 all do raw
strstrsearches:The pattern requires zero whitespace between
:and". Python'sjson.dumps()defaults to"text": "..."with a space after the colon. Many real-world API providers — including OpenAI's Responses API in some configurations — emit pretty-printed or space-separated JSON.Reproduction: Tier B test
ai_http_prompt_returns_okinitially failed withFailed to parse AI response.even though the stub returned a perfectly valid Responses-API body. The fix was to addseparators=(",", ":")tojson.dumpsin the stub. In production there is no such workaround: the first response that comes through any whitespace-tolerant serializer is silently dropped.This affects all three hand-rolled parsers: chat-content, error-message, and (separately fixed by
a898cb212for the models endpoint) model-list.Fix: accept
[ \t\n\r]*between key and value in all three parsers, or replace the trio withjson-glib. The latter also closes the rest of F15 (\\,\t,\r,\uXXXXdecoding still missing for chat-content and error paths).T2 —
/ai startwrites "Started AI chat" to a window the user can't seesrc/command/cmd_funcs.c:11000-11011 (
cmd_ai_start):ui_focus_winmakes the AI window current.cons_showwrites to the console window — which is no longer on screen. So the success line is functionally dead code: it never reaches the user.The actual user-visible feedback comes from the two
win_println(ai_win, ..., "AI Chat: %s/%s", ...)and"Type your message and press Enter..."lines a few lines above, which work as intended.While debugging the functional test
ai_start_with_key_opens_windowI had asserted on"Started AI chat: ..."and it never matched — because it never rendered. The test now asserts on"AI Chat: openai/gpt-4"from the WIN_AI title instead.Fix: drop the cons_show pair, or move it before
ui_focus_winif you want the console to retain a history of opened chats.T3 —
/ai models <provider>caches silently and refuses to print what it fetchedsrc/ai/ai_client.c:975-982 (
_models_response_handler):After
/ai models openaithe user sees:…but no list of what those 38 models are. To see them the user must immediately run
/ai models openai --cached. The whole point of an interactivemodelscommand is to discover available models — making people run two consecutive commands for that is a UX regression compared to the/ai providerscommand which is one-shot.Fix: at the end of
_models_response_handler, after the "Cached N models" line, iterateprovider->modelsandcons_show(" %s", ...)each entry. Or unify withcmd_ai_models's--cachedbranch.🟠 Findings from PR 113 review confirmed by tests
T4 —
org_idis write-only (re-confirms X5)Tier B tests with custom providers can set provider URL and token, but not organization ID. The command
/ai set orgwas removed inacae54305, yetAIProvider->org_idis still:ai_provider_new,ai_provider_unref,_build_curl_headersto emitOpenAI-Organization: <id>(src/ai/ai_client.c:585-588).So the field survives and is sent to every provider with
org_id != NULL, but nothing on the command surface can populate it. Pure dead-write.Fix: either restore
/ai set org, fold into/ai set custom <p> org <id>, or remove the field + header entirely.T5 — Models-fetch error path doesn't reuse
ai_parse_error_message(re-confirms X6)src/ai/ai_client.c:617-619 inside
_curl_exec_and_handle:Compare to the chat path which calls
ai_parse_error_messagefirst to extracterror.message. The asymmetry is user-visible: a 401 from/v1/modelsdumps the raw JSON envelope; the same 401 from/v1/responsesshows the human-readable string.ai_http_prompt_401_shows_errorpasses; a hypotheticalai_http_models_fetch_401_shows_errortest (not implemented, would be trivial) would show the asymmetry directly.Fix: in
_curl_exec_and_handle, tryai_parse_error_message(response->data)first, fall back to raw body.🟡 Lower-priority observations
T6 —
stbbr_stop()hangs inpthread_joinwhen no client ever connectedNot a profanity bug — a stabber library issue surfaced by AI tests that don't need XMPP.
Stack trace (gdb-attached during 7-minute hang of
ai_no_args_shows_helpteardown):If profanity never called
/connect, stabber's accept-loop thread blocks in accept() forever, andstbbr_stop()joins on it uninterruptibly.Workaround in tests:
ai_init_test()in test_ai.c wrapsinit_prof_testwith aprof_connect()so stabber sees a graceful disconnect at teardown. Selected via customPROF_FUNC_TEST_AI(...)macro infunctionaltests.c.Upstream fix would be:
stbbr_stop()shouldshutdown(listen_fd, SHUT_RDWR)before the join so accept() returns. Report to whichever fork of stabber is current; ifstabberis dead upstream, vendor a fix locally.T7 —
/aicommands taggedCMD_TAG_CHATbut don't require XMPPMinor design wart: all
/ai *entries in src/command/cmd_defs.c carryCMD_TAG_CHAT. They run fine offline (no XMPP connection needed). Auto-discovery tools and/help chatwill list AI commands as if they were chat-bound, which is confusing — and the AI client itself has no notion of XMPP state.Fix: introduce a new tag (
CMD_TAG_AI, or pile onto an existingCMD_TAG_UI), or just drop the tag from/ai *entries.What the tests did not catch
These would require thread-sanitization or carefully timed race conditions, neither of which a
cmocka+forkptyharness can reproduce. They remain open from the PR 113 review:_aiwin_validateref_count++incmd_ai_switchcmd_ai_clear/cmd_ai_switchmutate session while worker reads/ai switchagainst it concurrently.AIProvider*for up to 30s/ai remove providerduring in-flight fetch.A follow-up integration sweep with
-fsanitize=threadand a deliberately timed race scenario (long-blocking stub response + main-thread mutator) would be needed.Test-related minor changes shipped alongside
src/ai/ai_client.h— exposedai_parse_response/ai_parse_error_messageas public for testing (analogous to existingai_parse_models_from_json).tests/functionaltests/proftest.c—TEST_GROUPSbumped from 4 to 5; AI tests live in their own Group 5 to avoid the stabber poisoning observed when mixing with XMPP tests in Group 4.Makefile.am—dist_check_DATA = tests/functionaltests/ai_http_stub.pyso the Python stub ships in the source tarball.Bottom line
Three new production bugs (T1–T3), two confirmations of round-3 review findings (T4–T5), one upstream-stabber bug (T6), one taxonomy nit (T7). T1 is the highest-impact: any whitespace-tolerant JSON serializer at the provider end will currently produce silent "Failed to parse" errors that the user must dig out of the debug log to understand.
Potential issues in the AI client
Three places in the AI client code that look like they could cause problems. All have small, low-risk fixes if the diagnosis holds up.
Potential issue 1 — Possible deadlock when AI window is closed mid-request
Possible severity: high if reachable — could hang the UI thread.
Location: src/ai/ai_client.c,
_aiwin_display_error.The global ncurses-coordination mutex is acquired at the top. When the AI window has been closed mid-flight and
_aiwin_validatereturnsNULL, the function appears to return through theif (!aiwin)branch without unlocking. If that's right, the mutex would stay held for the rest of the process lifetime, and any subsequent attempt to take it (e.g. UI rendering on the main thread) could deadlock.For comparison, the sibling
_aiwin_display_responsea few lines below handles the same NULL case with anunlockbefore returning, which is what suggested the asymmetry might be unintentional rather than deliberate.Suspected reproduction path: send an AI prompt; close the AI window with
/wins close ai(or/close) before the response arrives. If the diagnosis is correct, profanity would become unresponsive as soon as the worker thread tries to deliver the error. Have not actually reproduced this — it's inferred from reading the code.Possible fix (if the analysis holds):
If
_aiwin_display_erroris meant to be called only with the lock already held by the caller (i.e. acquiring it here would itself be wrong), the picture is different — please correct me.Potential issue 2 — Session mutex may not be destroyed before its storage is freed
Possible severity: low — probably POSIX-undefined behaviour with no observable effect on common platforms; could leak kernel resources slowly.
Location: src/ai/ai_client.c,
ai_session_unref.Two things I'm not sure about:
pthread_mutex_destroy(&session->lock)doesn't appear to be called beforeg_free(session). POSIX says freeing the storage of a mutex without first destroying it is undefined; in practice glibc just leaks the kernel resource, so the symptom (if any) would be slow. Maybe there's a reason this is fine — initialization mode, or a wrapper elsewhere — that I missed.The
lock/unlockpair insideai_session_unrefmay be redundant for exclusion: the refcount just hit zero, so by construction no other thread should be holding a reference. It might still be there for memory-ordering, but if not, it could be removed.Possible fix (if applicable):
Potential issue 3 —
ai_providers_findmay crash onNULLsearch_strPossible severity: high if reachable — could SIGSEGV the runtime.
Location: src/ai/ai_client.c,
ai_providers_find.An earlier version guarded against a NULL
search_strby treating it as the empty string before forwarding toautocomplete_complete:The guard appears to have been dropped.
autocomplete_completedoesstrdup(search_str)on the first attempt — ifsearch_strisNULL, that'sstrdup(NULL), which is undefined and crashes on glibc inside__strlen_avx2. So passing NULL would likely segfault now where it used to return safely.Possibly reachable from:
test_ai_providers_find_null_search_str(passesNULLexplicitly to assert previous behaviour) — would presumably crash the test runner instead of returningNULL.test_ai_providers_find_cycling(usesNULLfor empty-prefix cycling).It's not clear whether NULL was meant to remain a documented input or whether the new contract is "callers must pass a non-NULL string". Either is fine, but if it's the latter, the function probably shouldn't crash —
g_return_val_if_fail+ an earlyNULLreturn would be defensive.Possible fix — either restore the guard, or document the new contract and assert it:
Summary
_aiwin_display_errordeadlockai_providers_find(NULL)possible segfaultTreat these as observations from a code read; happy to be wrong on any of them.
21147f525cto5a074b280dResolved via using g_strdup for NULL-safety
Possible issues in AI provider autocomplete
Observations made while writing unit tests against the merged
feat/aiHEAD. None of these were reproduced interactively — they're inferences from how the tests behave, so happy to be wrong on any of them.TL;DR: neither issue is reachable from the current UI today. Both are latent API-hygiene gaps — the function accepts inputs the implementation doesn't tolerate, but no in-tree caller actually feeds those inputs. Probably cheaper to close them now (one localized fix covers both) than to wait for the first direct caller that trips on them — once a new script, plugin, or downstream binding starts calling
ai_providers_finddirectly, the segfault and the cursor leak both become user-visible regressions to chase down after the fact.A regression test for Issue 1 lives in the source tree (
test_ai_providers_find_null_search_str) with its cmocka registration commented out — running it SIGSEGVs the runner. Issue 2 is parked in the lowest-priority backlog and has no in-suite regression test.Potential issue 1 —
ai_providers_find(NULL, ...)appears to SIGSEGVPossible severity: high if reachable — runtime crash.
Suspected trace:
g_strdupis NULL-safe and returns NULL, but that NULL flows downstream intog_str_to_ascii(NULL, NULL), which is undefined and crashes on glibc.Reachability:
cmd_ac.c's_autocomplete_param_commonbuildsprefixviamalloc(inp_len + 1)and forwards a non-NULL substring ofinput— there's no path where the autocomplete machinery passes NULL toai_providers_find.gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context)doesn't document a non-NULL requirement, and the empty-string path (ai_providers_find("", ...)) already does the "give me anything" job — it's plausible someone reads the header and assumes NULL is a synonym.A regression-style test exists in tests/unittests/test_ai_client.c (
test_ai_providers_find_null_search_str) but its registration in tests/unittests/unittests.c is commented out — running it crashes the cmocka runner mid-suite and prevents the rest of the suite from reporting. Re-enable once the NULL path is guarded.Possible fix (one of):
ai_providers_find: guard the public entry so NULL maps to""before forwarding:autocomplete_complete/_search: early-return NULL on NULLsearch_str. Defensive but a wider change.g_return_val_if_fail(search_str != NULL, NULL)at entry — converts UB into a graceful NULL return + GLib warning.Potential issue 2 — cycling cursor seems to leak across prefix changes
Status: out of scope for this PR — to be parked in the lowest-priority backlog. Not reachable from the UI today; only matters when/if a direct API caller appears. No in-suite regression test.
Possible severity: low in practice — UX guarded by input-loop reset; only exposed via direct API use. Same shape as Issue 1.
The pattern:
ai_providers_find(<prefix A>, ...)one or more times → cursor lands somewhere mid-list.ai_providers_find(<prefix B>, ...)→ expected to restart from B's first match.search_stris silently dropped becauseautocomplete_complete()'s "subsequent search attempt" branch keeps usingac->search_strcached from the first call. The leftover cursor advances forward in the list, ignoring the new prefix.Reachability:
Two paths can reach
ai_providers_find:Input loop (interactive):
_inp_rl_getc→_inp_edited(ch)→cmd_ac_reset()→ai_providers_reset_ac()fires on every backspace, ctrl-w, enter, and every printable character. Tab itself is not "edited" and does not reset. The result:ai_providers_findwith a different prefix without an intervening reset.Direct API callers (tests, future scripted clients): no reset between calls unless caller explicitly invokes
ai_providers_reset_ac().So in current production this is not user-reachable. The behaviour matters only as API hygiene — same posture as Issue 1.
Possible fix (one of):
Option A (preferred) — localized inside
ai_providers_findAbout 10 lines, contained to
src/ai/ai_client.c, no behavioural impact on other autocomplete consumers. Single PR, single reviewer. Same patch covers Issue 1 if combined with the NULL→""mapping.Don't forget to free
providers_ac_last_searchinai_client_shutdown.Option B (not recommended in this PR) — push detection into
autocomplete_completeThe code change itself is small (compare
ac->search_stragainst newsearch_strand reset cursor on difference), but the semantic risk is high and affects every command that uses the autocomplete library.The current "subsequent search ignores new
search_str" semantics is what makes Tab-Tab cycling work everywhere:/connect al<tab>→autocomplete_complete(ac, "al", ...)returnsalice. Line becomes/connect alice. Cursor stored./connect alice<tab>→autocomplete_complete(ac, "alice", ...). Current behaviour ignores the new prefix and walks forward from the cached"al"cursor → returnsalex. Cycling works.A naive "reset on prefix change" in
autocomplete_completewould, on Tab 2, see"al" != "alice", reset, and returnaliceagain. Tab-cycling would silently break in roughly every command (/connect,/msg,/join, the MUC roster, OMEMO fingerprints, plugin commands…).To preserve cycling, Option B has to be smarter — for example, treat the new
search_stras a "still the same query" when it's a known autocomplete result of the previous one (heuristic:g_str_has_prefix(new, old) && new is in ac->items). That's non-trivial, requires regression-testing every command that drives the AC library, and is much wider than this PR.In short: Option B is a multi-day refactor with broad blast radius. Option A is a half-hour patch contained to
src/ai/.The existing
cmd_ac_resethook keeps working either way; Option A also leaves the input-loop integration untouched.Summary
search_strSIGSEGVcmd_ac.c(always passes a malloc'd substring). Reachable from any direct caller / future API user.test_ai_providers_find_null_search_strpresent in source, registration commented out (crashes the runner).cmd_ac_reset). Reachable from direct API callers.Issue 2 disposition: out of scope for the current PR — file under lowest-priority backlog. No user-visible impact today; revisit if a direct API caller surfaces.
Both look like the same shape: the public
ai_providers_find()accepts inputs the implementation doesn't tolerate, but the only existing in-tree caller never actually feeds it those inputs. The single-function fix sketched under Issue 2 (*_last_searchcache + NULL→""mapping) addresses both at once.Treat these as observations from a code read plus failing-test signal; pleased to be corrected if either is a deliberate design choice.
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: - Console only (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. 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. 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 - 15/15 AI functional tests pass (Group 5)Adds 10 tests for ai_models_find and additional edge cases on ai_providers_find. 5 pass as sanity; 5 fail intentionally, documenting two distinct bugs in the autocomplete integration: BUG A — ai_models_find cycling is broken src/ai/ai_client.c ai_models_find() allocates a fresh Autocomplete on every call and frees it before returning. The Autocomplete's last_found cursor is therefore never persisted, so repeated calls with the same prefix always return the first match. Tab+Tab+Tab cannot reach models 2, 3, ... Caught by: - test_ai_models_find_cycles_through_matches - test_ai_models_find_null_search_cycles_all BUG B — providers_ac is never reset between user input cycles src/ai/ai_client.c keeps the static providers_ac AC, but src/command/cmd_ac.c's all_acs[] (which cmd_ac_reset() iterates) does not include &providers_ac. So when the input loop fires the global reset, providers_ac retains its last_found cursor from the previous prefix. Result: tab-completing a new prefix continues from the old cursor position instead of restarting at the head of the new prefix's matches. A provider added mid-session may also be missed depending on cursor position. Caught by: - test_ai_providers_find_state_resets_on_prefix_change - test_ai_providers_find_empty_then_prefix - test_ai_providers_find_after_add_includes_new Sanity (5 passing): - test_ai_models_find_no_models_returns_null - test_ai_models_find_returns_match_for_prefix - test_ai_models_find_no_match_returns_null - test_ai_models_find_previous_direction - test_ai_providers_find_after_remove_skips_removed The failing tests are checked in as-is — they go green when the bugs are fixed. See PR description for suggested fixes.Both the chat response parser and the error envelope parser were doing their own ad-hoc strstr scans and inline unescape loops, with different sets of supported escape sequences (chat: \" \n; error: \" \n \\ \t). Models endpoint errors didn't try to extract error.message at all and surfaced raw response bodies to the user — inconsistent with the chat path which already did parse them. Consolidate around two helpers: _json_unescape_substring: decode \" \\ \/ \n \t \r \b \f into a fresh buffer; pass \uXXXX through verbatim (out of scope here). _extract_json_string: locate "field":"..." via _find_json_field (already handles JSON whitespace around the colon) and return the unescaped value, treating any \X inside the string body as opaque so embedded \" is never misread as the closing quote. _parse_ai_response and _parse_error_response now each collapse to a small wrapper around these helpers. Wire _parse_error_response into the shared _curl_exec_and_handle 4xx branch so models requests also surface the provider's error.message instead of the raw body.WIP: [#110] Add AI client with multi-provider support and UIto [#110] Add AI client with multi-provider support and UI