From 391a7f216fe6491c4449d6351f90db929dd70020 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 22:13:21 +0200 Subject: [PATCH] fix: more fixes to AI chat --- lib/bds/ai/chat.ex | 36 +++++++++- .../shell_live/chat_editor/message_build.ex | 26 +++++++ test/bds/ai_test.exs | 11 +++ test/bds/desktop/shell_live_test.exs | 67 +++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index f3aa36a..d7e602a 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -479,10 +479,44 @@ defmodule BDS.AI.Chat do case Catalog.decode_nullable_json(message.tool_calls) do nil -> base - tool_calls -> Map.put(base, "tool_calls", tool_calls) + tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls)) end end + defp tool_calls_for_runtime(tool_calls) when is_list(tool_calls) do + Enum.map(tool_calls, &tool_call_for_runtime/1) + end + + defp tool_calls_for_runtime(tool_calls), do: tool_calls + + defp tool_call_for_runtime(%{"type" => "function", "function" => %{} = _function} = tool_call) do + tool_call + end + + defp tool_call_for_runtime(%{"id" => id, "name" => name} = tool_call) do + %{ + "id" => id, + "type" => "function", + "function" => %{ + "name" => name, + "arguments" => Jason.encode!(tool_call["arguments"] || %{}) + } + } + end + + defp tool_call_for_runtime(%{id: id, name: name} = tool_call) do + %{ + "id" => id, + "type" => "function", + "function" => %{ + "name" => name, + "arguments" => Jason.encode!(Map.get(tool_call, :arguments) || %{}) + } + } + end + + defp tool_call_for_runtime(tool_call), do: tool_call + defp truncate_chat_messages(messages, model, tools) do context_window = model_context_window(model) reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512)) diff --git a/lib/bds/desktop/shell_live/chat_editor/message_build.ex b/lib/bds/desktop/shell_live/chat_editor/message_build.ex index ea1d893..50bac29 100644 --- a/lib/bds/desktop/shell_live/chat_editor/message_build.ex +++ b/lib/bds/desktop/shell_live/chat_editor/message_build.ex @@ -62,6 +62,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do next_turn_index = turn_index + 1 {entries, start_entry(message, next_turn_index, assigns), next_turn_index} + :assistant -> + next_entry = start_entry(message, turn_index, assigns) + + if tool_only_assistant_entry?(current_entry) do + {entries, merge_tool_only_entry(current_entry, next_entry), turn_index} + else + entries = finalize_entry(entries, current_entry) + {entries, next_entry, turn_index} + end + _other -> entries = finalize_entry(entries, current_entry) {entries, start_entry(message, turn_index, assigns), turn_index} @@ -99,6 +109,22 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do end end + defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do + String.trim(content || "") == "" and + (entry.tool_markers != [] or entry.inline_surfaces != [] or entry.tool_surfaces != []) + end + + defp tool_only_assistant_entry?(_entry), do: false + + defp merge_tool_only_entry(tool_entry, assistant_entry) do + %{ + assistant_entry + | tool_markers: tool_entry.tool_markers ++ assistant_entry.tool_markers, + inline_surfaces: tool_entry.inline_surfaces ++ assistant_entry.inline_surfaces, + tool_surfaces: tool_entry.tool_surfaces ++ assistant_entry.tool_surfaces + } + end + defp pending_user_message(_messages, nil), do: nil defp pending_user_message(messages, %{message: message}) when is_binary(message) do diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index 3726abf..870ced7 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -602,6 +602,17 @@ defmodule BDS.AITest do assert get_in(render_chart_schema, ["series", "items", "properties", "segments"]) != nil assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end) + + assert Enum.any?(second_request.messages, fn message -> + message["role"] == "assistant" and + message["tool_calls"] == [ + %{ + "id" => "call-blog-stats", + "type" => "function", + "function" => %{"name" => "blog_stats", "arguments" => "{}"} + } + ] + end) end test "chat does not prompt models to emit textual tool calls when tools are unavailable" do diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 2ff3772..6b10b5a 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -2216,6 +2216,73 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Posts" end + test "chat editor folds tool-only assistant steps into the final assistant answer" do + assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"}) + + now = Persistence.now_ms() + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :user, + content: "Show posts per month", + created_at: now + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :assistant, + content: nil, + tool_calls: + Jason.encode!([ + %{ + "id" => "call-count-posts", + "name" => "count_posts", + "arguments" => %{"groupBy" => ["month"], "year" => 2026} + } + ]), + created_at: now + 1 + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :tool, + tool_call_id: "call-count-posts", + content: Jason.encode!([%{"month" => 5, "count" => 3}]), + created_at: now + 2 + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :assistant, + content: "Here is the chart.", + created_at: now + 3 + }) + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert html =~ "count_posts" + assert html =~ "Here is the chart." + assert html =~ ~s(Assistant) + + assert length(:binary.matches(html, ~s(Assistant))) == 1 + end + test "chat editor marks user message text as compact" do assert {:ok, conversation} = AI.start_chat(%{title: "Compact Chat", model: "gpt-4.1"})