From 6b94208daabfe75f43d9743c048fca68bfad72d9 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 21 Jul 2026 14:22:09 -0600 Subject: [PATCH 1/5] server: disable tool calls during compaction requests When the Responses API input history ends with a "compaction" or "context_compaction" bookkeeping item, the model was still emitting DSML tool calls as part of its summary output. These tool calls were returned as raw text in the response content, causing breakage in clients like opencode that expect structured tool_calls or plain text. Track whether the last input item was a compaction type and disable has_tools in that case, so the model generates a plain text summary without attempting tool invocations. Fixes bogus DSML appearing in compaction/summary responses. --- ds4_server.c | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ds4_server.c b/ds4_server.c index 45a53469c..bba95b311 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -3472,7 +3472,9 @@ static bool parse_responses_content_array(const char **p, char **out) { * render_chat_prompt_text can wrap them in . */ static bool parse_responses_input(const char **p, chat_msgs *msgs, buf *loaded_tool_schemas, - tool_schema_orders *orders) { + tool_schema_orders *orders, + bool *last_was_compaction) { + if (last_was_compaction) *last_was_compaction = false; json_ws(p); if (**p != '[') return false; (*p)++; @@ -3720,6 +3722,7 @@ static bool parse_responses_input(const char **p, chat_msgs *msgs, !strcmp(t, "tool_search_call") || !strcmp(t, "image_generation_call"); bool is_bookkeeping = !strcmp(t, "compaction") || !strcmp(t, "context_compaction"); + if (is_bookkeeping && last_was_compaction) *last_was_compaction = true; if (!consumes_reasoning && !is_bookkeeping && pending_reasoning.len) { chat_msg flush_msg = {0}; flush_msg.role = xstrdup("assistant"); @@ -4021,6 +4024,7 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, const char *p = body; bool got_input = false; bool tool_choice_none = false; + bool last_was_compaction = false; bool got_thinking = false; bool thinking_enabled = true; ds4_think_mode reasoning_effort = DS4_THINK_HIGH; @@ -4058,7 +4062,7 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, msg.content = plain; chat_msgs_push(&msgs, msg); } else if (!parse_responses_input(&p, &msgs, &loaded_tool_schemas, - &r->tool_orders)) { + &r->tool_orders, &last_was_compaction)) { free(key); goto bad; } @@ -4240,7 +4244,7 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, const char *active_tool_schemas = (!tool_choice_none && combined_tool_schemas.len) ? combined_tool_schemas.ptr : NULL; - r->has_tools = active_tool_schemas && active_tool_schemas[0]; + r->has_tools = active_tool_schemas && active_tool_schemas[0] && !last_was_compaction; if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false; if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true; r->think_mode = ds4_think_mode_for_context( @@ -13371,7 +13375,7 @@ static void test_responses_input_tool_search_output_loads_tools(void) { chat_msgs msgs = {0}; buf loaded = {0}; tool_schema_orders orders = {0}; - TEST_ASSERT(parse_responses_input(&p, &msgs, &loaded, &orders)); + TEST_ASSERT(parse_responses_input(&p, &msgs, &loaded, &orders, NULL)); TEST_ASSERT(loaded.ptr && strstr(loaded.ptr, "\"name\":\"mcp__perplexity__perplexity_search\"")); const tool_schema_order *order = tool_schema_orders_find(&orders, "mcp__perplexity__perplexity_search"); @@ -13396,7 +13400,7 @@ static void test_responses_input_tool_search_output_rejects_bad_tools(void) { chat_msgs msgs = {0}; buf loaded = {0}; tool_schema_orders orders = {0}; - TEST_ASSERT(!parse_responses_input(&p, &msgs, &loaded, &orders)); + TEST_ASSERT(!parse_responses_input(&p, &msgs, &loaded, &orders, NULL)); buf_free(&loaded); tool_schema_orders_free(&orders); chat_msgs_free(&msgs); @@ -13419,7 +13423,7 @@ static void test_responses_input_function_call_namespace_round_trips_to_dsml(voi "\"arguments\":{\"query\":\"deepseek\"}}]"; const char *input_p = input_json; chat_msgs msgs = {0}; - TEST_ASSERT(parse_responses_input(&input_p, &msgs, NULL, NULL)); + TEST_ASSERT(parse_responses_input(&input_p, &msgs, NULL, NULL, NULL)); TEST_ASSERT(msgs.len == 1); TEST_ASSERT(msgs.v[0].calls.len == 1); TEST_ASSERT(!strcmp(msgs.v[0].calls.v[0].name, From c652525354029caefb5bb07d14e8e54f7cac03af Mon Sep 17 00:00:00 2001 From: user Date: Thu, 23 Jul 2026 16:25:31 -0600 Subject: [PATCH 2/5] server: strip tool schemas from compaction prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix only set has_tools=false when the Responses input ended with a compaction item, but still rendered the full tool schemas and DSML usage instructions into the prompt. The model, primed by both the schemas and a history saturated with DSML stanzas, kept emitting tool calls as its "summary" — and with has_tools off nothing parsed them, so the raw DSML leaked into the response text and became the stored compaction summary, corrupting the compacted context. Drop active_tool_schemas as well for compaction requests, matching the tool-less chat completions path: no schemas, no DSML instructions, plain text summary. Also fix last_was_compaction latching: it was set by any bookkeeping item in the input and never cleared, so a compaction item anywhere in history would disable tools for the whole request. Now only the last input item decides, as the original commit intended. Verified live: compaction-last request renders 14 prompt tokens with no TOOLS flag; the same request with a trailing user message renders 334 tokens with TOOLS enabled. --- ds4_server.c | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/ds4_server.c b/ds4_server.c index bba95b311..1e04168e0 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -3722,7 +3722,7 @@ static bool parse_responses_input(const char **p, chat_msgs *msgs, !strcmp(t, "tool_search_call") || !strcmp(t, "image_generation_call"); bool is_bookkeeping = !strcmp(t, "compaction") || !strcmp(t, "context_compaction"); - if (is_bookkeeping && last_was_compaction) *last_was_compaction = true; + if (last_was_compaction) *last_was_compaction = is_bookkeeping; if (!consumes_reasoning && !is_bookkeeping && pending_reasoning.len) { chat_msg flush_msg = {0}; flush_msg.role = xstrdup("assistant"); @@ -4241,10 +4241,14 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, buf_append(&combined_tool_schemas, loaded_tool_schemas.ptr, loaded_tool_schemas.len); } + /* Compaction requests must not prime the model for tools at all: with + * schemas still in the prompt the model keeps emitting DSML stanzas, and + * with has_tools disabled nothing parses them, so they leak raw into the + * summary text. Drop the schemas too, like the tool-less chat path. */ const char *active_tool_schemas = - (!tool_choice_none && combined_tool_schemas.len) ? + (!tool_choice_none && !last_was_compaction && combined_tool_schemas.len) ? combined_tool_schemas.ptr : NULL; - r->has_tools = active_tool_schemas && active_tool_schemas[0] && !last_was_compaction; + r->has_tools = active_tool_schemas && active_tool_schemas[0]; if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false; if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true; r->think_mode = ds4_think_mode_for_context( @@ -13441,6 +13445,31 @@ static void test_responses_input_function_call_namespace_round_trips_to_dsml(voi tool_schema_orders_free(&orders); } +static void test_responses_input_compaction_flag_tracks_last_item(void) { + const char *ends_with_compaction = + "[{\"type\":\"message\",\"role\":\"user\",\"content\":\"hi\"}," + "{\"type\":\"compaction\"}]"; + const char *p = ends_with_compaction; + chat_msgs msgs = {0}; + bool last_was_compaction = false; + TEST_ASSERT(parse_responses_input(&p, &msgs, NULL, NULL, &last_was_compaction)); + TEST_ASSERT(last_was_compaction); + chat_msgs_free(&msgs); + + /* A compaction item earlier in the history must not disable tools for + * this request: only the last item decides. Regression test for the + * latch that kept tools off for every turn after the first compaction. */ + const char *compaction_then_user = + "[{\"type\":\"compaction\"}," + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"continue\"}]"; + p = compaction_then_user; + memset(&msgs, 0, sizeof(msgs)); + last_was_compaction = false; + TEST_ASSERT(parse_responses_input(&p, &msgs, NULL, NULL, &last_was_compaction)); + TEST_ASSERT(!last_was_compaction); + chat_msgs_free(&msgs); +} + static void test_responses_output_sends_tool_search_call_item(void) { tool_calls calls = {0}; tool_call tc = {0}; @@ -17437,6 +17466,7 @@ static void ds4_server_unit_tests_run(void) { test_responses_input_tool_search_output_loads_tools(); test_responses_input_tool_search_output_rejects_bad_tools(); test_responses_input_function_call_namespace_round_trips_to_dsml(); + test_responses_input_compaction_flag_tracks_last_item(); test_responses_output_sends_tool_search_call_item(); test_dsml_tool_args_preserve_call_order(); test_openai_tool_args_preserve_call_order(); From d08d1d86eff846c3e19b52864e56131e6a14eee7 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 23 Jul 2026 20:05:02 -0600 Subject: [PATCH 3/5] server: sanitize compaction prompts and strip leaked DSML --- ds4_server.c | 71 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/ds4_server.c b/ds4_server.c index 1e04168e0..d00e86372 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -634,6 +634,7 @@ typedef struct { int cache_write_tokens; ds4_think_mode think_mode; bool has_tools; + bool compaction; bool prompt_preserves_reasoning; /* For /v1/responses: emit reasoning_summary_* events / fields only when the * client opted in via reasoning.summary. Other APIs leave this false; the @@ -2427,6 +2428,57 @@ static bool chat_history_uses_tool_context(const chat_msgs *msgs, return false; } +/* Remove raw DSML tool-call blocks that may have been stored in message content + * or generated by the model during compaction/summarization requests. */ +static char *strip_dsml_markup(const char *s) { + if (!s) return NULL; + static const char start_tag[] = "<|DSML|tool_calls>"; + static const char end_tag[] = ""; + const size_t start_len = sizeof(start_tag) - 1; + const size_t end_len = sizeof(end_tag) - 1; + buf out = {0}; + const char *p = s; + while (*p) { + const char *start = strstr(p, start_tag); + if (!start) { + buf_puts(&out, p); + break; + } + buf_append(&out, p, (size_t)(start - p)); + const char *end = strstr(start + start_len, end_tag); + if (!end) { + /* Unterminated block: drop the remainder. */ + break; + } + p = end + end_len; + } + return buf_take(&out); +} + +/* Build a compaction-safe copy of the conversation history: + * - adds an explicit plain-text instruction, + * - strips any raw DSML blocks stored in assistant content, + * - drops structured tool_calls so the renderer cannot emit DSML examples. + */ +static chat_msgs sanitize_messages_for_compaction(const chat_msgs *msgs) { + chat_msgs out = {0}; + chat_msg instr = {0}; + instr.role = xstrdup("system"); + instr.content = xstrdup( + "This request is a conversation compaction/summarization. " + "Output plain text only. Do not emit tool calls, DSML markup, or XML tags."); + chat_msgs_push(&out, instr); + for (int i = 0; i < msgs->len; i++) { + const chat_msg *m = &msgs->v[i]; + chat_msg copy = {0}; + copy.role = xstrdup(m->role); + copy.content = strip_dsml_markup(m->content); + copy.reasoning = xstrdup(m->reasoning); + chat_msgs_push(&out, copy); + } + return out; +} + static char *render_deepseek_chat_prompt_text(const chat_msgs *msgs, const char *tool_schemas, const tool_schema_orders *tool_orders, ds4_think_mode think_mode) { @@ -4249,6 +4301,7 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, (!tool_choice_none && !last_was_compaction && combined_tool_schemas.len) ? combined_tool_schemas.ptr : NULL; r->has_tools = active_tool_schemas && active_tool_schemas[0]; + r->compaction = last_was_compaction; if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false; if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true; r->think_mode = ds4_think_mode_for_context( @@ -4267,13 +4320,17 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, } kv_cache_restore_tool_memory_for_messages(s, &msgs); tool_memory_attach_to_messages(s, &msgs, &r->tool_replay); - r->prompt_preserves_reasoning = - chat_history_uses_tool_context(&msgs, active_tool_schemas); responses_prepare_live_continuation(r, &msgs); + chat_msgs prompt_msgs = last_was_compaction + ? sanitize_messages_for_compaction(&msgs) + : msgs; + r->prompt_preserves_reasoning = + chat_history_uses_tool_context(&prompt_msgs, active_tool_schemas); r->prompt_text = render_chat_prompt_text_for_syntax( - r->model_syntax, &msgs, active_tool_schemas, + r->model_syntax, &prompt_msgs, active_tool_schemas, &r->tool_orders, r->think_mode); ds4_tokenize_rendered_chat(e, r->prompt_text, &r->prompt); + if (last_was_compaction) chat_msgs_free(&prompt_msgs); chat_msgs_free(&msgs); buf_free(&combined_tool_schemas); buf_free(&loaded_tool_schemas); @@ -11738,6 +11795,14 @@ static void generate_job(server *s, server_slot *slot, job *j) { char *parsed_reasoning = NULL; const char *final_finish = finish; bool recovered_tool_parse_failure = false; + if (j->req.kind == REQ_CHAT && j->req.compaction && text.ptr) { + /* Defense in depth: if the model still emits DSML during a compaction + * request, strip it before the response is finalized. */ + char *stripped = strip_dsml_markup(text.ptr); + free(text.ptr); + text.ptr = stripped ? stripped : xstrdup(""); + text.len = strlen(text.ptr); + } if (j->req.kind == REQ_CHAT) { bool parsed_ok = parse_generated_message_for_response_for_syntax( j->req.model_syntax, From d65eff257e38cb5b85574a654f78dc7de3992414 Mon Sep 17 00:00:00 2001 From: Jaime Fournier Date: Fri, 24 Jul 2026 11:09:16 -0600 Subject: [PATCH 4/5] server: detect opencode compaction on the chat-completions path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous three compaction fixes only patched the Responses API (last_was_compaction bookkeeping in parse_responses_input). opencode talks to ds4 through @ai-sdk/openai-compatible, which posts compaction requests to /v1/chat/completions with the full tool schemas attached — so none of the earlier handling ever fired, and long sessions kept stalling: primed by the schemas and a DSML-saturated history, the model emitted |DSML|tool_calls stanzas as its "summary", corrupting the compacted context every time auto-compaction ran. Detect opencode's compaction request in parse_chat_request by its distinctive summarization markers (the anchored-summary system prompt, , or the create/update anchored-summary user instruction) and apply the Responses-path treatment: drop tool schemas, sanitize the history (strip stored DSML, prepend the plain-text instruction, drop structured tool_calls), and set r->compaction so the output-side DSML strip still catches anything that leaks through. Regression test covers the system-prompt marker, the previous-summary update marker, and a negative case that merely mentions summarizing. --- ds4_server.c | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/ds4_server.c b/ds4_server.c index d00e86372..f7815ac14 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -2455,6 +2455,34 @@ static char *strip_dsml_markup(const char *s) { return buf_take(&out); } +/* opencode and other chat-completions clients send compaction/summarization + * requests through /v1/chat/completions, not the Responses API, so the + * last_was_compaction bookkeeping never fires for them. Detect those + * requests by their distinctive summarization instructions instead: + * opencode's compaction system prompt ("anchored context summarization + * assistant") or the trailing user instruction asking for the anchored + * summary. Without this, compaction requests arrive with full tool schemas + * and a DSML-saturated history, the model emits tool-call stanzas as its + * "summary", and the session stalls on a corrupted compaction. */ +static bool chat_msgs_look_like_compaction(const chat_msgs *msgs) { + if (!msgs) return false; + static const char *const markers[] = { + "anchored context summarization assistant", + "", + "Create a new anchored summary from the conversation history", + "Update the anchored summary below using the conversation history above", + }; + for (int i = 0; i < msgs->len; i++) { + const chat_msg *m = &msgs->v[i]; + if (!m->content) continue; + if (!role_is_system(m->role) && strcmp(m->role, "user")) continue; + for (size_t k = 0; k < sizeof(markers) / sizeof(markers[0]); k++) { + if (strstr(m->content, markers[k])) return true; + } + } + return false; +} + /* Build a compaction-safe copy of the conversation history: * - adds an explicit plain-text instruction, * - strips any raw DSML blocks stored in assistant content, @@ -3145,7 +3173,15 @@ static bool parse_chat_request(ds4_engine *e, server *s, const char *body, int d request_free(r); return false; } - r->has_tools = tool_schemas && tool_schemas[0] && !tool_choice_none; + /* Compaction/summarization requests must not be primed for tools: with + * schemas rendered and a DSML-heavy history, the model emits tool-call + * stanzas as its "summary", corrupting the compacted context. Mirror + * the Responses-path handling: drop the schemas, sanitize the history, + * and mark the request so any leaked DSML is stripped from the reply. */ + const bool compaction_request = chat_msgs_look_like_compaction(&msgs); + r->has_tools = tool_schemas && tool_schemas[0] && !tool_choice_none && + !compaction_request; + r->compaction = compaction_request; if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false; if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true; r->think_mode = ds4_think_mode_for_context( @@ -3153,12 +3189,16 @@ static bool parse_chat_request(ds4_engine *e, server *s, const char *body, int d kv_cache_restore_tool_memory_for_messages(s, &msgs); tool_memory_attach_to_messages(s, &msgs, &r->tool_replay); const char *active_tool_schemas = r->has_tools ? tool_schemas : NULL; + chat_msgs prompt_msgs = compaction_request + ? sanitize_messages_for_compaction(&msgs) + : msgs; r->prompt_preserves_reasoning = - chat_history_uses_tool_context(&msgs, active_tool_schemas); + chat_history_uses_tool_context(&prompt_msgs, active_tool_schemas); r->prompt_text = render_chat_prompt_text_for_syntax( - r->model_syntax, &msgs, active_tool_schemas, + r->model_syntax, &prompt_msgs, active_tool_schemas, &r->tool_orders, r->think_mode); ds4_tokenize_rendered_chat(e, r->prompt_text, &r->prompt); + if (compaction_request) chat_msgs_free(&prompt_msgs); chat_msgs_free(&msgs); free(tool_schemas); return true; @@ -13535,6 +13575,45 @@ static void test_responses_input_compaction_flag_tracks_last_item(void) { chat_msgs_free(&msgs); } +static void test_chat_request_detects_opencode_compaction(void) { + /* opencode sends compaction through /v1/chat/completions with a + * distinctive summarization system prompt and full tool schemas; the + * chat path must detect it independently of the Responses bookkeeping. */ + chat_msgs msgs = {0}; + chat_msg sys = {0}; + sys.role = xstrdup("system"); + sys.content = xstrdup( + "You are an anchored context summarization assistant for coding sessions.\n" + "Summarize only the conversation history you are given."); + chat_msgs_push(&msgs, sys); + chat_msg user = {0}; + user.role = xstrdup("user"); + user.content = xstrdup("Create a new anchored summary from the conversation history."); + chat_msgs_push(&msgs, user); + TEST_ASSERT(chat_msgs_look_like_compaction(&msgs)); + chat_msgs_free(&msgs); + + /* A previous-summary update prompt is also compaction. */ + memset(&msgs, 0, sizeof(msgs)); + chat_msg upd = {0}; + upd.role = xstrdup("user"); + upd.content = xstrdup( + "Update the anchored summary below using the conversation history above.\n" + "\nold\n"); + chat_msgs_push(&msgs, upd); + TEST_ASSERT(chat_msgs_look_like_compaction(&msgs)); + chat_msgs_free(&msgs); + + /* Ordinary chat that merely mentions summarizing must not be flagged. */ + memset(&msgs, 0, sizeof(msgs)); + chat_msg plain = {0}; + plain.role = xstrdup("user"); + plain.content = xstrdup("please summarize this file for me"); + chat_msgs_push(&msgs, plain); + TEST_ASSERT(!chat_msgs_look_like_compaction(&msgs)); + chat_msgs_free(&msgs); +} + static void test_responses_output_sends_tool_search_call_item(void) { tool_calls calls = {0}; tool_call tc = {0}; @@ -17532,6 +17611,7 @@ static void ds4_server_unit_tests_run(void) { test_responses_input_tool_search_output_rejects_bad_tools(); test_responses_input_function_call_namespace_round_trips_to_dsml(); test_responses_input_compaction_flag_tracks_last_item(); + test_chat_request_detects_opencode_compaction(); test_responses_output_sends_tool_search_call_item(); test_dsml_tool_args_preserve_call_order(); test_openai_tool_args_preserve_call_order(); From c559d9359cdc1c40bdf5cc02751d22544d0cdf1f Mon Sep 17 00:00:00 2001 From: Jaime Fournier Date: Fri, 24 Jul 2026 11:16:57 -0600 Subject: [PATCH 5/5] server: fix NULL-reasoning crash in compaction sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitize_messages_for_compaction unconditionally xstrdup'd m->reasoning, but xstrdup is NULL-unsafe and chat-completions messages carry reasoning == NULL unless the client sends the field — the first opencode compaction request after the chat-path detection segfaulted the server. Only the Responses parser always populates reasoning, which is why the original live verification missed it. Guard the copy and add a regression test that sanitizes a message with no reasoning field. --- ds4_server.c | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ds4_server.c b/ds4_server.c index f7815ac14..7a77a703f 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -2501,7 +2501,9 @@ static chat_msgs sanitize_messages_for_compaction(const chat_msgs *msgs) { chat_msg copy = {0}; copy.role = xstrdup(m->role); copy.content = strip_dsml_markup(m->content); - copy.reasoning = xstrdup(m->reasoning); + /* chat-completions messages usually have reasoning == NULL (only the + * Responses parser always populates it), and xstrdup is NULL-unsafe. */ + copy.reasoning = m->reasoning ? xstrdup(m->reasoning) : NULL; chat_msgs_push(&out, copy); } return out; @@ -13612,6 +13614,20 @@ static void test_chat_request_detects_opencode_compaction(void) { chat_msgs_push(&msgs, plain); TEST_ASSERT(!chat_msgs_look_like_compaction(&msgs)); chat_msgs_free(&msgs); + + /* sanitize_messages_for_compaction must survive reasoning == NULL, which + * is the norm for chat-completions messages (xstrdup is NULL-unsafe). */ + memset(&msgs, 0, sizeof(msgs)); + chat_msg bare = {0}; + bare.role = xstrdup("user"); + bare.content = xstrdup("no reasoning field here"); + chat_msgs_push(&msgs, bare); + chat_msgs clean = sanitize_messages_for_compaction(&msgs); + TEST_ASSERT(clean.len == 2); + TEST_ASSERT(!strcmp(clean.v[1].role, "user")); + TEST_ASSERT(clean.v[1].reasoning == NULL); + chat_msgs_free(&clean); + chat_msgs_free(&msgs); } static void test_responses_output_sends_tool_search_call_item(void) {