The defaults_initialized sentinel only makes sense when prefs is loaded.
Three unit tests use ai_client_setup, which calls ai_client_init without
loading prefs — prefs_ai_get_providers was bailing on the seeding branch
because of the prefs NULL check, leaving ai_client with zero providers
and breaking tests that expect at least one default.
Decouple the two concerns:
- Always seed openai/perplexity into the in-memory result list when the
configured set is empty and we haven't already seeded.
- Only persist the seeded providers and write the defaults_initialized
flag when prefs is actually available.
In production this is identical to the previous behaviour; in tests
without prefs the old "always-return-defaults" semantics is preserved
without a persistence side effect.
Also reflow ai_client.c JSON escape switch and HTTP-error ternary to
satisfy clang-format 18 (CI checks).
Pull in the defaults-agnostic unit and functional test coverage so this
branch carries both the source fixes and the tests that exercise them.
# Conflicts:
# src/ai/ai_client.c
Per-provider state used to live as flat keys in [ai] with naming
conventions to avoid collisions: <name>_url, <name>_models,
<name>_default_model, and the bare <name> for the API token. Custom
settings (/ai set custom) never made it to disk at all — they only
lived in the runtime AIProvider.settings hash table and were lost on
restart.
Move every per-provider field into a [ai/<name>] subsection of the
keyfile with stable key names:
[ai/openai]
url=https://api.openai.com/
key=sk-...
default_model=gpt-4
models=gpt-4;gpt-3.5-turbo
setting.tools=enabled
[ai] now keeps only the global "defaults_initialized" sentinel.
Migration runs once in _prefs_load: collect provider names from the
old <name>_url keys, then move url/models/default_model/token of each
provider into its subsection and drop the flat keys. Idempotent on
subsequent loads.
prefs_ai_* API surface kept compatible; storage backend changes only.
Adds:
prefs_ai_remove_section() — wipe whole subsection in one call
prefs_ai_{set,get,remove,list}_setting() — persist custom settings
ai_client wiring:
- ai_set_provider_setting now writes to prefs as well as the runtime
hash table; ai_client_init loads them back on startup.
- ai_remove_provider replaces four prefs_ai_remove_* calls with a
single prefs_ai_remove_section.
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.
- Persist per-provider default model in [ai] prefs (set/get/remove
helpers, load on init, clear with provider removal). Previously
/ai set default-model only updated runtime state.
- Stop re-seeding openai/perplexity into [ai] every time the configured
provider list is empty. The defaults are now seeded once, guarded by
a "defaults_initialized" sentinel, so removing every provider does
not silently restore them.
- /ai providers (no arg) now lists the configured providers with key
status, instead of the hardcoded openai/perplexity blurb.
- /ai models <provider> (fresh fetch) prints the model names it cached,
not just a count. Previously the user had to follow up with --cached
to actually see them.
- /ai remove provider now also clears the persisted default model,
token, and cached model list, leaving no orphan keys in [ai].
- Drop dead ai_models_find declaration from ai_client.h (no
implementation in this tree).
Self-setup providers in tests instead of relying on hardcoded openai/perplexity
defaults. Multi-provider tests now use distinct URLs per provider; functional
tests check for http/https URL presence rather than specific provider names.
Drop ai_models_find tests (function removed upstream in feat/ai). Replace with
reset-hook + persistence coverage: providers_reset_ac restart cycle, provider
add/remove round-trip across init, model cache round-trip across init.
Keep three autocomplete prefix-change tests red in-suite (documented as latent
API-hygiene gap, unreachable from UI today). NULL-search test body retained
but registration commented out — currently SIGSEGVs the cmocka runner.
Add stub_xmpp connection_create_stanza_id to satisfy new cmd_funcs.c call site.
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.
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)