diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e4228..ff8856e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Live config reload reaches the Anthropic provider (`provider_anthropic_set_live_config`, guarded like OpenAI), so dashboard/SIGHUP reloads update model and `max_tokens` instead of staying on the config captured at init. +- Parallel tool results are grouped into one `user` message (one `tool_result` block per call, per the Messages API contract), fixing Anthropic 400s on parallel tool calls. +- The local provider re-probes on the next request after an unreachable startup, at most once every 15 seconds, so a down `llama-server` no longer costs synchronous curl probes on every agent turn. - Dashboard and cron SQLite access is serialized with `agent_lock()` (matching `POST /asap`), including `DELETE /api/sessions/{id}` session deletion, so the lws service thread cannot race `agent_run` / `cron_poll` on the shared `g_db` (`SQLITE_THREADSAFE=0`). - `interval:` schedules reject intervals that do not fit `now + seconds` and numbers that stop parsing early (`interval:60abc`), instead of wrapping to a past `next_run` that stays due and re-fires forever. - Cron schedules reject partial numbers instead of silently accepting the numeric prefix: `at:2026-10-08` (would become 2026), comma lists such as `0 9 1,15 * *`, and step syntax are now parse errors. diff --git a/docs/LOCAL_INFERENCE.md b/docs/LOCAL_INFERENCE.md index 0c76275..bf3cde9 100644 --- a/docs/LOCAL_INFERENCE.md +++ b/docs/LOCAL_INFERENCE.md @@ -18,7 +18,7 @@ llama-server (127.0.0.1:8080) GGUF model on disk (/var/lib/shellclaw/models/) ``` -At startup, `local.c` probes `GET /health` and `GET /v1/models`. If unreachable, the local provider marks itself unavailable and the router falls through to the next entry in `providers.fallback_chain`. +At startup, `local.c` probes `GET /health` and `GET /v1/models`. If unreachable, the local provider marks itself unavailable and the router falls through to the next entry in `providers.fallback_chain`. After an unreachable startup the provider re-probes on the next request, at most once every 15 seconds, so a `llama-server` that comes up later can still serve. --- diff --git a/src/core/reload.c b/src/core/reload.c index 17c64e5..cbad861 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -87,6 +87,7 @@ int try_config_reload(config_t **pcfg) return -1; } provider_router_set_live_config(new_cfg); + provider_anthropic_set_live_config(new_cfg); provider_openai_set_live_config(new_cfg); provider_local_set_live_config(new_cfg); tool_set_config(new_cfg); diff --git a/src/providers/anthropic.c b/src/providers/anthropic.c index 2801427..998aa87 100644 --- a/src/providers/anthropic.c +++ b/src/providers/anthropic.c @@ -265,6 +265,111 @@ static int do_request(const char *body, provider_response_t *response) return ret; } +static int append_one_tool_result(cJSON *content_arr, const provider_message_t *msg) +{ + cJSON *tr = cJSON_CreateObject(); + if (!tr) + return -1; + if (!cJSON_AddStringToObject(tr, "type", "tool_result") || + !cJSON_AddStringToObject(tr, "tool_use_id", msg->tool_use_id ? msg->tool_use_id : "") || + !cJSON_AddStringToObject(tr, "content", msg->content ? msg->content : "")) { + cJSON_Delete(tr); + return -1; + } + cJSON_AddItemToArray(content_arr, tr); + return 0; +} + +/* One user message must carry every tool_result for that assistant turn. */ +static int append_tool_result_message(cJSON *msg_arr, const provider_message_t *messages, + size_t message_count, size_t *index) +{ + cJSON *msg = cJSON_CreateObject(); + cJSON *content_arr = cJSON_CreateArray(); + if (!msg || !content_arr) { + cJSON_Delete(msg); + cJSON_Delete(content_arr); + return -1; + } + cJSON_AddItemToObject(msg, "role", cJSON_CreateString("user")); + cJSON_AddItemToObject(msg, "content", content_arr); + cJSON_AddItemToArray(msg_arr, msg); + for (;;) { + if (append_one_tool_result(content_arr, &messages[*index]) != 0) + return -1; + if (*index + 1 >= message_count || !messages[*index + 1].tool_use_id) + break; + (*index)++; + } + return 0; +} +static int append_assistant_tool_content(cJSON *msg, const provider_message_t *message) +{ + cJSON *content_arr = cJSON_CreateArray(); + size_t k; + if (!content_arr) + return -1; + if (message->content && message->content[0] != '\0') { + cJSON *text_block = cJSON_CreateObject(); + if (!text_block) { + cJSON_Delete(content_arr); + return -1; + } + cJSON_AddItemToObject(text_block, "type", cJSON_CreateString("text")); + cJSON_AddItemToObject(text_block, "text", cJSON_CreateString(message->content)); + cJSON_AddItemToArray(content_arr, text_block); + } + for (k = 0; k < message->tool_calls_count; k++) { + const provider_tool_call_t *tc = &message->tool_calls[k]; + cJSON *tu = cJSON_CreateObject(); + cJSON *input; + if (!tu) { + cJSON_Delete(content_arr); + return -1; + } + cJSON_AddItemToObject(tu, "type", cJSON_CreateString("tool_use")); + cJSON_AddItemToObject(tu, "id", cJSON_CreateString(tc->id ? tc->id : "")); + cJSON_AddItemToObject(tu, "name", cJSON_CreateString(tc->name ? tc->name : "")); + input = tc->arguments && tc->arguments[0] ? cJSON_Parse(tc->arguments) : cJSON_CreateObject(); + if (input) + cJSON_AddItemToObject(tu, "input", input); + cJSON_AddItemToArray(content_arr, tu); + } + cJSON_AddItemToObject(msg, "content", content_arr); + return 0; +} + +static int append_anthropic_messages(cJSON *msg_arr, const provider_message_t *messages, + size_t message_count) +{ + size_t i; + for (i = 0; i < message_count; i++) { + const char *role = messages[i].role ? messages[i].role : "user"; + cJSON *msg; + if (strcmp(role, "system") == 0) + continue; + if (messages[i].tool_use_id) { + if (append_tool_result_message(msg_arr, messages, message_count, &i) != 0) + return -1; + continue; + } + msg = cJSON_CreateObject(); + if (!msg) + return -1; + cJSON_AddItemToArray(msg_arr, msg); + cJSON_AddItemToObject(msg, "role", cJSON_CreateString(role)); + if (messages[i].tool_calls && messages[i].tool_calls_count > 0 && + strcmp(role, "assistant") == 0) { + if (append_assistant_tool_content(msg, &messages[i]) != 0) + return -1; + } else { + cJSON_AddItemToObject(msg, "content", + cJSON_CreateString(messages[i].content ? messages[i].content : "")); + } + } + return 0; +} + static int build_and_send(const provider_message_t *messages, size_t message_count, const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) @@ -286,59 +391,12 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou } cJSON *msg_arr = cJSON_CreateArray(); if (!msg_arr) { cJSON_Delete(root); provider_set_error(response, "Out of memory"); return -1; } - for (size_t i = 0; i < message_count; i++) { - const char *role = messages[i].role ? messages[i].role : "user"; - if (strcmp(role, "system") == 0) continue; - cJSON *msg = cJSON_CreateObject(); - if (!msg) { - cJSON_Delete(root); - provider_set_error(response, "Out of memory"); - return -1; - } - cJSON_AddItemToObject(msg, "role", cJSON_CreateString(role)); - if (messages[i].tool_use_id) { - cJSON *content_arr = cJSON_CreateArray(); - if (content_arr) { - cJSON *tr = cJSON_CreateObject(); - if (tr) { - cJSON_AddItemToObject(tr, "type", cJSON_CreateString("tool_result")); - cJSON_AddItemToObject(tr, "tool_use_id", cJSON_CreateString(messages[i].tool_use_id)); - cJSON_AddItemToObject(tr, "content", cJSON_CreateString(messages[i].content ? messages[i].content : "")); - cJSON_AddItemToArray(content_arr, tr); - } - cJSON_AddItemToObject(msg, "content", content_arr); - } else - cJSON_AddItemToObject(msg, "content", cJSON_CreateString("")); - } else if (messages[i].tool_calls && messages[i].tool_calls_count > 0 && strcmp(role, "assistant") == 0) { - cJSON *content_arr = cJSON_CreateArray(); - if (content_arr) { - if (messages[i].content && messages[i].content[0] != '\0') { - cJSON *text_block = cJSON_CreateObject(); - if (text_block) { - cJSON_AddItemToObject(text_block, "type", cJSON_CreateString("text")); - cJSON_AddItemToObject(text_block, "text", cJSON_CreateString(messages[i].content)); - cJSON_AddItemToArray(content_arr, text_block); - } - } - for (size_t k = 0; k < messages[i].tool_calls_count; k++) { - const provider_tool_call_t *tc = &messages[i].tool_calls[k]; - cJSON *tu = cJSON_CreateObject(); - if (!tu) break; - cJSON_AddItemToObject(tu, "type", cJSON_CreateString("tool_use")); - cJSON_AddItemToObject(tu, "id", cJSON_CreateString(tc->id ? tc->id : "")); - cJSON_AddItemToObject(tu, "name", cJSON_CreateString(tc->name ? tc->name : "")); - cJSON *input = tc->arguments && tc->arguments[0] ? cJSON_Parse(tc->arguments) : cJSON_CreateObject(); - if (input) cJSON_AddItemToObject(tu, "input", input); - cJSON_AddItemToArray(content_arr, tu); - } - cJSON_AddItemToObject(msg, "content", content_arr); - } else - cJSON_AddItemToObject(msg, "content", cJSON_CreateString(messages[i].content ? messages[i].content : "")); - } else - cJSON_AddItemToObject(msg, "content", cJSON_CreateString(messages[i].content ? messages[i].content : "")); - cJSON_AddItemToArray(msg_arr, msg); - } cJSON_AddItemToObject(root, "messages", msg_arr); + if (append_anthropic_messages(msg_arr, messages, message_count) != 0) { + cJSON_Delete(root); + provider_set_error(response, "Out of memory"); + return -1; + } if (tool_count > 0 && tools) { cJSON *tools_arr = cJSON_CreateArray(); if (tools_arr) { @@ -414,10 +472,39 @@ const provider_t *provider_anthropic_get(void) return &anthropic_provider; } +void provider_anthropic_set_live_config(const config_t *cfg) +{ + if (cfg && s_anth_api_key) + s_anth_cfg = cfg; +} + +const config_t *anthropic_live_config_for_test(void) +{ + return s_anth_cfg; +} + #ifdef SHELLCLAW_TEST int anthropic_parse_response_for_test(const char *json, provider_response_t *response) { if (!json || !response) return -1; return parse_response_body(json, response); } + +char *anthropic_messages_json_for_test(const provider_message_t *messages, size_t message_count) +{ + cJSON *arr; + char *printed; + if (!messages && message_count > 0) + return NULL; + arr = cJSON_CreateArray(); + if (!arr) + return NULL; + if (append_anthropic_messages(arr, messages, message_count) != 0) { + cJSON_Delete(arr); + return NULL; + } + printed = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + return printed; +} #endif diff --git a/src/providers/local.c b/src/providers/local.c index 5a3f537..1bcd4aa 100644 --- a/src/providers/local.c +++ b/src/providers/local.c @@ -1,6 +1,7 @@ /** * @file local.c * @brief OpenAI-compatible Chat Completions client for llama-server; init probe marks unreachable without failing startup. + * A later chat probes again so a server that was down only at startup can still serve. */ #define _POSIX_C_SOURCE 200809L @@ -14,14 +15,17 @@ #include #include #include +#include #define REQUEST_TIMEOUT_SEC 120 #define CONNECT_TIMEOUT_SEC 30 #define LOCAL_PROBE_TIMEOUT_SEC 3L #define LOCAL_PROBE_CONNECT_SEC 2L +#define LOCAL_REPROBE_MIN_SEC 15L static const config_t *s_local_cfg; static int s_local_unavailable; +static time_t s_local_reprobe_last_wall; #ifdef SHELLCLAW_TEST static const char *s_local_test_http_body; static int s_local_test_skip_probe; @@ -184,6 +188,24 @@ static int local_chat(const provider_message_t *messages, size_t message_count, provider_set_error(response, "Local provider not initialized"); return -1; } + /* Init probes once, often before llama-server has loaded the model. + * Default fallback order puts local last, so router recovery (which + * only probes backends before the active index) never clears the flag. */ + if (s_local_unavailable) { + const char *endpoint = config_provider_local_endpoint(s_local_cfg); + time_t now = time(NULL); + /* agent_run holds agent_lock while this probe runs synchronously; + * at most one probe per LOCAL_REPROBE_MIN_SEC while unavailable, + * else a down llama-server blocks the gateway/channel every turn. */ + int reprobe_due = s_local_reprobe_last_wall == (time_t)0 || + (unsigned long)(now - s_local_reprobe_last_wall) >= + (unsigned long)LOCAL_REPROBE_MIN_SEC; + if (reprobe_due) { + s_local_reprobe_last_wall = now; + if (endpoint && endpoint[0] != '\0' && provider_local_endpoint_reachable(endpoint)) + provider_local_recovery_probe_succeeded(); + } + } if (s_local_unavailable) { provider_set_error(response, "Local inference server unreachable"); return -1; @@ -196,6 +218,7 @@ static void local_cleanup(void) { s_local_cfg = NULL; s_local_unavailable = 0; + s_local_reprobe_last_wall = (time_t)0; } #ifdef SHELLCLAW_TEST @@ -209,6 +232,7 @@ void local_provider_test_reset(void) s_local_test_http_body = NULL; s_local_test_skip_probe = 0; s_local_unavailable = 0; + s_local_reprobe_last_wall = (time_t)0; } void local_provider_test_set_http_response(const char *json_body) diff --git a/src/providers/provider.h b/src/providers/provider.h index 6b8fa1a..385b200 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -124,6 +124,10 @@ void provider_stub_b_set_chat_error_message(const char *message); /** Anthropic (Claude) provider. Messages API, tool_use. */ const provider_t *provider_anthropic_get(void); +/** After SIGHUP or dashboard reload: refresh model and max_tokens without re-init. */ +void provider_anthropic_set_live_config(const config_t *cfg); +/** Config pointer Anthropic chat reads. NULL until init. For tests. */ +const config_t *anthropic_live_config_for_test(void); /** OpenAI provider. Chat Completions, tool_calls (function calling). */ const provider_t *provider_openai_get(void); @@ -134,7 +138,7 @@ void provider_openai_set_live_config(const config_t *cfg); const provider_t *provider_local_get(void); /** After SIGHUP reload: refresh live config reads without re-init (PRD ยง9 Q4). */ void provider_local_set_live_config(const config_t *cfg); -/** Lightweight GET probe via `/v1/models` or `/health`; used by init and router recovery. */ +/** Lightweight GET probe via `/v1/models` or `/health`; used by init, chat retry, and router recovery. */ int provider_local_endpoint_reachable(const char *endpoint); /** After a successful recovery GET probe on local; clears init-time unreachable flag. */ void provider_local_recovery_probe_succeeded(void); diff --git a/tests/test_anthropic.c b/tests/test_anthropic.c index a82b28f..dba4f50 100644 --- a/tests/test_anthropic.c +++ b/tests/test_anthropic.c @@ -6,6 +6,7 @@ #include "core/config.h" #include "providers/provider.h" +#include "cJSON.h" #include #include #include @@ -15,6 +16,7 @@ #ifdef SHELLCLAW_TEST extern int anthropic_parse_response_for_test(const char *json, provider_response_t *response); +extern char *anthropic_messages_json_for_test(const provider_message_t *messages, size_t message_count); extern void anthropic_test_fail_next_reallocs(int n); #endif @@ -233,6 +235,226 @@ static int test_parse_text_realloc_failure_is_error(void) return 0; } +static int test_parallel_tool_results_share_one_user_message(void) +{ + provider_tool_call_t calls[2] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + {.id = "toolu_b", .name = "read_file", .arguments = "{\"path\":\"a.txt\"}"}, + }; + provider_message_t messages[4]; + char *json; + cJSON *arr; + cJSON *assistant; + cJSON *user; + cJSON *content; + cJSON *role; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "do both"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls; + messages[1].tool_calls_count = 2; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + messages[3].role = "user"; + messages[3].content = "out-b"; + messages[3].tool_use_id = "toolu_b"; + json = anthropic_messages_json_for_test(messages, 4); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 3); + assistant = cJSON_GetArrayItem(arr, 1); + content = assistant ? cJSON_GetObjectItem(assistant, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 2); + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 2); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_a") == 0); + block = cJSON_GetArrayItem(content, 1); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_b") == 0); + cJSON_Delete(arr); + return 0; +} + +/** Old path unregressed: one tool result still yields exactly one user message. */ +static int test_single_tool_result_one_user_message(void) +{ + provider_tool_call_t calls[1] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + }; + provider_message_t messages[3]; + char *json; + cJSON *arr; + cJSON *user; + cJSON *role; + cJSON *content; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "run pwd"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls; + messages[1].tool_calls_count = 1; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + json = anthropic_messages_json_for_test(messages, 3); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 3); + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_a") == 0); + cJSON_Delete(arr); + return 0; +} + +/** Grouping must stop at the first message without a tool_use_id. */ +static int test_tool_results_stop_at_plain_user_message(void) +{ + provider_tool_call_t calls[2] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + {.id = "toolu_b", .name = "read_file", .arguments = "{\"path\":\"a.txt\"}"}, + }; + provider_message_t messages[5]; + char *json; + cJSON *arr; + cJSON *user; + cJSON *role; + cJSON *content; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "do both"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls; + messages[1].tool_calls_count = 2; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + messages[3].role = "user"; + messages[3].content = "out-b"; + messages[3].tool_use_id = "toolu_b"; + messages[4].role = "user"; + messages[4].content = "what now?"; + json = anthropic_messages_json_for_test(messages, 5); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 4); + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 2); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_a") == 0); + block = cJSON_GetArrayItem(content, 1); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_b") == 0); + /* The plain user message after the results stays its own message. */ + user = cJSON_GetArrayItem(arr, 3); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsString(content) && strcmp(content->valuestring, "what now?") == 0); + cJSON_Delete(arr); + return 0; +} + +/** Results of a second round must not merge across the assistant message. */ +static int test_tool_results_do_not_merge_across_assistant_rounds(void) +{ + provider_tool_call_t calls_a[1] = { + {.id = "toolu_a", .name = "shell", .arguments = "{\"command\":\"pwd\"}"}, + }; + provider_tool_call_t calls_c[1] = { + {.id = "toolu_c", .name = "read_file", .arguments = "{\"path\":\"b.txt\"}"}, + }; + provider_message_t messages[5]; + char *json; + cJSON *arr; + cJSON *assistant; + cJSON *user; + cJSON *role; + cJSON *content; + cJSON *block; + + memset(messages, 0, sizeof(messages)); + messages[0].role = "user"; + messages[0].content = "start"; + messages[1].role = "assistant"; + messages[1].content = ""; + messages[1].tool_calls = calls_a; + messages[1].tool_calls_count = 1; + messages[2].role = "user"; + messages[2].content = "out-a"; + messages[2].tool_use_id = "toolu_a"; + messages[3].role = "assistant"; + messages[3].content = ""; + messages[3].tool_calls = calls_c; + messages[3].tool_calls_count = 1; + messages[4].role = "user"; + messages[4].content = "out-c"; + messages[4].tool_use_id = "toolu_c"; + json = anthropic_messages_json_for_test(messages, 5); + ASSERT(json != NULL); + arr = cJSON_Parse(json); + cJSON_free(json); + ASSERT(arr != NULL && cJSON_IsArray(arr)); + ASSERT(cJSON_GetArraySize(arr) == 5); + /* Round one result message: exactly its own block. */ + user = cJSON_GetArrayItem(arr, 2); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + /* The second assistant round keeps its own assistant message. */ + assistant = cJSON_GetArrayItem(arr, 3); + role = assistant ? cJSON_GetObjectItem(assistant, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "assistant") == 0); + content = assistant ? cJSON_GetObjectItem(assistant, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_c") == 0); + /* Round two result lands in its own user message. */ + user = cJSON_GetArrayItem(arr, 4); + role = user ? cJSON_GetObjectItem(user, "role") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0); + content = user ? cJSON_GetObjectItem(user, "content") : NULL; + ASSERT(cJSON_IsArray(content) && cJSON_GetArraySize(content) == 1); + block = cJSON_GetArrayItem(content, 0); + role = block ? cJSON_GetObjectItem(block, "tool_use_id") : NULL; + ASSERT(cJSON_IsString(role) && strcmp(role->valuestring, "toolu_c") == 0); + cJSON_Delete(arr); + return 0; +} + static int test_parse_tool_realloc_failure_is_error(void) { char body[1024]; @@ -268,6 +490,10 @@ int main(void) RUN(test_parse_null_json_returns_error()); RUN(test_parse_text_past_initial_cap()); RUN(test_parse_six_tool_use_blocks()); + RUN(test_parallel_tool_results_share_one_user_message()); + RUN(test_single_tool_result_one_user_message()); + RUN(test_tool_results_stop_at_plain_user_message()); + RUN(test_tool_results_do_not_merge_across_assistant_rounds()); RUN(test_parse_text_realloc_failure_is_error()); RUN(test_parse_tool_realloc_failure_is_error()); #endif diff --git a/tests/test_local_provider.c b/tests/test_local_provider.c index 859f679..1b4dc13 100644 --- a/tests/test_local_provider.c +++ b/tests/test_local_provider.c @@ -75,6 +75,120 @@ static int test_init_unreachable_sets_unavailable_then_chat_errors(void) return 0; } +/** + * Init probe failure must not stick for the process lifetime. llama-server + * often is not listening yet at startup; the next chat has to probe again. + */ +static int test_chat_reprobes_when_server_comes_up(void) +{ + FILE *f = fopen(TMP_CFG, "w"); + config_t *cfg = NULL; + char errbuf[256]; + const provider_t *p; + provider_message_t msg = { .role = "user", .content = "ping", .tool_calls = NULL, + .tool_calls_count = 0, .tool_use_id = NULL }; + provider_response_t response = {0}; + ASSERT(f); + local_provider_test_reset(); + unsetenv("SHELLCLAW_FALLBACK_CHAIN"); + unsetenv("SHELLCLAW_LOCAL_ENDPOINT"); + unsetenv("SHELLCLAW_LOCAL_MODEL"); + fprintf(f, "[agent]\nmodel = \"test-model\"\n"); + fprintf(f, "[providers.local]\n"); + fprintf(f, "endpoint = \"http://127.0.0.1:1/v1/chat/completions\"\nmodel = \"tiny\"\n"); + fclose(f); + ASSERT(config_load(TMP_CFG, &cfg, errbuf, sizeof(errbuf)) == 0); + p = provider_local_get(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "back online") != NULL); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() == 0); +#endif + p->cleanup(); + provider_response_clear(&response); + local_provider_test_reset(); + config_free(cfg); + remove(TMP_CFG); + return 0; +} + +/** + * While unavailable, chat re-probes at most once per LOCAL_REPROBE_MIN_SEC: + * agent_run holds agent_lock during chat, so a down llama-server must not + * cost synchronous curl probes on every turn. The second chat inside the + * window must not probe (skip_probe would otherwise recover it through the + * mock); after the test hook resets the throttle it must probe again. + */ +static int test_chat_reprobe_throttled_within_min_interval(void) +{ + FILE *f = fopen(TMP_CFG, "w"); + config_t *cfg = NULL; + char errbuf[256]; + const provider_t *p; + provider_message_t msg = { .role = "user", .content = "ping", .tool_calls = NULL, + .tool_calls_count = 0, .tool_use_id = NULL }; + provider_response_t response = {0}; + ASSERT(f); + local_provider_test_reset(); + unsetenv("SHELLCLAW_FALLBACK_CHAIN"); + unsetenv("SHELLCLAW_LOCAL_ENDPOINT"); + unsetenv("SHELLCLAW_LOCAL_MODEL"); + fprintf(f, "[agent]\nmodel = \"test-model\"\n"); + fprintf(f, "[providers.local]\n"); + fprintf(f, "endpoint = \"http://127.0.0.1:1/v1/chat/completions\"\nmodel = \"tiny\"\n"); + fclose(f); + ASSERT(config_load(TMP_CFG, &cfg, errbuf, sizeof(errbuf)) == 0); + p = provider_local_get(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + /* First chat after the startup miss: the probe runs against port 1 + * and fails, arming the throttle window. */ + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == -1); + ASSERT(response.error != 0); + ASSERT(strstr(response.content, "unreachable") != NULL); + provider_response_clear(&response); + /* Second chat inside the window: no probe, so skip_probe cannot clear + * the flag and the mock is not served. */ + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == -1); + ASSERT(response.error != 0); + ASSERT(strstr(response.content, "unreachable") != NULL); + provider_response_clear(&response); + /* After the hook resets the throttle and init re-marks unavailable, + * the next chat must probe again and recover through the mock. */ + local_provider_test_reset(); + ASSERT(p->init(cfg) == 0); +#ifdef SHELLCLAW_TEST + ASSERT(local_provider_is_unavailable_for_test() != 0); +#endif + local_provider_test_set_skip_probe(1); + local_provider_test_set_http_response( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"back online\"}}]}"); + ASSERT(p->chat(&msg, 1, NULL, 0, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "back online") != NULL); + p->cleanup(); + provider_response_clear(&response); + local_provider_test_reset(); + config_free(cfg); + remove(TMP_CFG); + return 0; +} + static int test_chat_mock_http_happy_path(void) { FILE *f = fopen(TMP_CFG, "w"); @@ -113,6 +227,8 @@ int main(void) RUN(test_local_provider_vtable()); RUN(test_init_requires_config()); RUN(test_init_unreachable_sets_unavailable_then_chat_errors()); + RUN(test_chat_reprobes_when_server_comes_up()); + RUN(test_chat_reprobe_throttled_within_min_interval()); RUN(test_chat_mock_http_happy_path()); curl_global_cleanup(); printf("test_local_provider: all tests passed\n"); diff --git a/tests/test_reload.c b/tests/test_reload.c index 37e7a75..7c07693 100644 --- a/tests/test_reload.c +++ b/tests/test_reload.c @@ -2,12 +2,15 @@ * @file test_reload.c * @brief Unit tests for SIGHUP config reload and stale config queue. */ +#define _POSIX_C_SOURCE 200809L #include "test_runner.h" #include "core/reload.h" #include "core/bootstrap.h" #include "core/config.h" +#include "providers/provider.h" #include +#include #include static config_t *load_minimal_config(const char *path, const char *model) @@ -212,8 +215,49 @@ static int test_try_config_reload_ignores_stale_caller_pointer(void) return 0; } +static int test_try_config_reload_updates_anthropic_model(void) +{ + char path[128]; + config_t *cfg = NULL; + const provider_t *anth; + const config_t *live; + + ASSERT(test_runner_mkstemp_path("shellclaw_test_reload", path, sizeof(path)) == 0); + cfg = load_config_with_tokens(path, "model-before", 111); + ASSERT(cfg != NULL); + bootstrap_set_config_path(path); + bootstrap_set_cfg(cfg); + ASSERT(setenv("ANTHROPIC_API_KEY", "test-key-not-sent", 1) == 0); + anth = provider_anthropic_get(); + ASSERT(anth != NULL && anth->init != NULL && anth->cleanup != NULL); + ASSERT(anth->init(cfg) == 0); + live = anthropic_live_config_for_test(); + ASSERT(live != NULL); + ASSERT(strcmp(config_agent_model(live), "model-before") == 0); + ASSERT(config_agent_max_tokens(live) == 111); + { + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"model-after\"\nmax_tokens = 222\n"); + fclose(f); + } + ASSERT(try_config_reload(&cfg) == 0); + live = anthropic_live_config_for_test(); + ASSERT(live != NULL); + ASSERT(strcmp(config_agent_model(live), "model-after") == 0); + ASSERT(config_agent_max_tokens(live) == 222); + anth->cleanup(); + stale_free_all(); + config_free(cfg); + bootstrap_set_cfg(NULL); + unsetenv("ANTHROPIC_API_KEY"); + remove(path); + return 0; +} + int main(void) { + RUN(test_try_config_reload_updates_anthropic_model()); RUN(test_on_hup_sets_reload_flag()); RUN(test_stale_enqueue_null_is_noop()); RUN(test_stale_enqueue_preserves_old_config());