diff --git a/lib/bds/ai/catalog.ex b/lib/bds/ai/catalog.ex index 6f7051e..7614c3d 100644 --- a/lib/bds/ai/catalog.ex +++ b/lib/bds/ai/catalog.ex @@ -111,7 +111,8 @@ defmodule BDS.AI.Catalog do def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do capabilities = %{ supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)), - supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)) + supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)), + disables_reasoning: truthy?(BDS.MapUtils.attr(attrs, :disables_reasoning)) } put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) @@ -163,7 +164,8 @@ defmodule BDS.AI.Catalog do @spec model_capabilities(String.t()) :: %{ supports_attachment: boolean(), - supports_tool_calls: boolean() + supports_tool_calls: boolean(), + disables_reasoning: boolean() } def model_capabilities(model_id) do overrides = decode_model_capabilities_override(model_id) @@ -173,7 +175,8 @@ defmodule BDS.AI.Catalog do {:ok, model} -> %{ supports_attachment: model.supports_attachment or "image" in model.input_modalities, - supports_tool_calls: model.supports_tool_calls + supports_tool_calls: model.supports_tool_calls, + disables_reasoning: false } _other -> @@ -196,7 +199,8 @@ defmodule BDS.AI.Catalog do String.contains?(normalized, "llava"), supports_tool_calls: String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or - String.contains?(normalized, "tool") + String.contains?(normalized, "tool"), + disables_reasoning: false } end diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index ea05e13..531b510 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do @moduledoc false import Ecto.Query + require Logger alias BDS.AI alias BDS.AI.Catalog @@ -23,7 +24,7 @@ defmodule BDS.AI.Chat do @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_max_output_tokens 16_384 - @title_max_output_tokens 20 + @title_max_output_tokens 256 @chat_title_max_length 30 @chat_max_tool_rounds 10 @default_context_window 128_000 @@ -383,7 +384,7 @@ defmodule BDS.AI.Chat do conversation = Repo.get!(ChatConversation, conversation_id) cond do - chat_user_message_count(conversation_id) != 1 -> + chat_user_message_count(conversation_id) < 1 -> {:ok, reply} not generated_chat_title?(conversation.title, conversation.model) -> @@ -418,7 +419,25 @@ defmodule BDS.AI.Chat do :ok <- Runtime.validate_target(:chat_title, model, mode), request <- build_chat_title_request(user_content, model), {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do - {:ok, sanitize_chat_title(Map.get(response, :content))} + title = sanitize_chat_title(Map.get(response, :content)) + + if title == "" do + Logger.warning("Chat title generation returned an empty title", + model: model, + content: inspect(Map.get(response, :content)), + usage: inspect(Map.get(response, :usage)) + ) + end + + {:ok, title} + else + {:error, reason} = error -> + Logger.warning("Chat title generation failed", reason: inspect(reason)) + error + + other -> + Logger.warning("Chat title generation failed", reason: inspect(other)) + other end end @@ -431,7 +450,7 @@ defmodule BDS.AI.Chat do %{ "role" => "system", "content" => - "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text." + "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text." }, %{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"} ] diff --git a/lib/bds/ai/openai_compatible_runtime.ex b/lib/bds/ai/openai_compatible_runtime.ex index 5d76ef6..d4ac24c 100644 --- a/lib/bds/ai/openai_compatible_runtime.ex +++ b/lib/bds/ai/openai_compatible_runtime.ex @@ -36,6 +36,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do "messages" => request.messages, "max_tokens" => request.max_output_tokens } + |> maybe_disable_thinking(request.model) |> maybe_put_tools(Map.get(request, :tools, [])) with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), @@ -136,6 +137,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do |> Map.put("tool_choice", "auto") end + defp maybe_disable_thinking(payload, model) when is_binary(model) do + if BDS.AI.Catalog.model_capabilities(model).disables_reasoning do + Map.update(payload, "chat_template_kwargs", %{"enable_thinking" => false}, fn kwargs -> + Map.put(kwargs || %{}, "enable_thinking", false) + end) + else + payload + end + end + + defp maybe_disable_thinking(payload, _model), do: payload + defp normalize_tool_calls(tool_calls) do Enum.map(tool_calls, fn tool_call -> %{ diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 795ffc4..2585f1e 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do import Phoenix.HTML, only: [raw: 1] alias BDS.AI + alias BDS.MapUtils alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} @@ -249,8 +250,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do ) case result do - {:ok, _reply} -> - reload.(socket, socket.assigns.workbench) + {:ok, reply} -> + socket + |> update_tab_meta_from_reply(conversation_id, reply) + |> reload.(socket.assigns.workbench) {:error, :cancelled} -> reload.(socket, socket.assigns.workbench) @@ -266,6 +269,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do end end + defp update_tab_meta_from_reply(socket, conversation_id, reply) do + title = + reply + |> MapUtils.attr(:conversation, %{}) + |> MapUtils.attr(:title) + + if is_binary(title) and String.trim(title) != "" do + key = {:chat, conversation_id} + + assign(socket, :tab_meta, Map.update(socket.assigns.tab_meta, key, %{title: title}, fn meta -> + Map.put(meta, :title, title) + end)) + else + socket + end + end + # ── HEEx-callable helpers ───────────────────────────────────────────────── @spec message_role_label(term()) :: term() diff --git a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex index b141427..f829966 100644 --- a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex @@ -21,6 +21,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do model_supports_tool_calls?( get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "") ), + "online_chat_disable_reasoning" => + model_disables_reasoning?( + get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "") + ), "online_title_model" => get_model_preference(:title), "online_image_analysis_model" => get_model_preference(:image_analysis), "offline_url" => Map.get(airplane_endpoint || %{}, :url, ""), @@ -33,6 +37,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do model_supports_tool_calls?( get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "") ), + "offline_chat_disable_reasoning" => + model_disables_reasoning?( + get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "") + ), "offline_title_model" => get_model_preference(:airplane_title), "offline_image_analysis_model" => get_model_preference(:airplane_image_analysis), "system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || "" @@ -99,12 +107,20 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do :ok <- AI.set_airplane_mode(attrs.offline_mode), :ok <- maybe_put_model_preference(:chat, attrs.online_chat_model), :ok <- - maybe_put_chat_model_capabilities(attrs.online_chat_model, attrs.online_chat_tools), + maybe_put_chat_model_capabilities( + attrs.online_chat_model, + attrs.online_chat_tools, + attrs.online_chat_disable_reasoning + ), :ok <- maybe_put_model_preference(:title, attrs.online_title_model), :ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model), :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model), :ok <- - maybe_put_chat_model_capabilities(attrs.offline_chat_model, attrs.offline_chat_tools), + maybe_put_chat_model_capabilities( + attrs.offline_chat_model, + attrs.offline_chat_tools, + attrs.offline_chat_disable_reasoning + ), :ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model), :ok <- maybe_put_model_preference( @@ -147,6 +163,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do online_api_key: blank_to_nil(Map.get(draft, "online_api_key")), online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")), online_chat_tools: truthy?(Map.get(draft, "online_chat_tools")), + online_chat_disable_reasoning: truthy?(Map.get(draft, "online_chat_disable_reasoning")), online_title_model: blank_to_nil(Map.get(draft, "online_title_model")), online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")), offline_url: blank_to_nil(Map.get(draft, "offline_url")), @@ -154,6 +171,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do offline_mode: truthy?(Map.get(draft, "offline_mode")), offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")), offline_chat_tools: truthy?(Map.get(draft, "offline_chat_tools")), + offline_chat_disable_reasoning: truthy?(Map.get(draft, "offline_chat_disable_reasoning")), offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")), offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")), system_prompt: Map.get(draft, "system_prompt", "") @@ -166,6 +184,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do "online_api_key" => Map.get(params, "online_api_key", ""), "online_chat_model" => Map.get(params, "online_chat_model", ""), "online_chat_tools" => truthy?(Map.get(params, "online_chat_tools")), + "online_chat_disable_reasoning" => + truthy?(Map.get(params, "online_chat_disable_reasoning")), "online_title_model" => Map.get(params, "online_title_model", ""), "online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""), "offline_url" => Map.get(params, "offline_url", ""), @@ -173,6 +193,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do "offline_mode" => truthy?(Map.get(params, "offline_mode")), "offline_chat_model" => Map.get(params, "offline_chat_model", ""), "offline_chat_tools" => truthy?(Map.get(params, "offline_chat_tools")), + "offline_chat_disable_reasoning" => + truthy?(Map.get(params, "offline_chat_disable_reasoning")), "offline_title_model" => Map.get(params, "offline_title_model", ""), "offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""), "system_prompt" => Map.get(params, "system_prompt", "") @@ -190,15 +212,16 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do defp maybe_put_model_preference(_key, ""), do: :ok defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value) - defp maybe_put_chat_model_capabilities(nil, _supports_tool_calls), do: :ok - defp maybe_put_chat_model_capabilities("", _supports_tool_calls), do: :ok + defp maybe_put_chat_model_capabilities(nil, _supports_tool_calls, _disables_reasoning), do: :ok + defp maybe_put_chat_model_capabilities("", _supports_tool_calls, _disables_reasoning), do: :ok - defp maybe_put_chat_model_capabilities(model, supports_tool_calls) do + defp maybe_put_chat_model_capabilities(model, supports_tool_calls, disables_reasoning) do existing = BDS.AI.Catalog.model_capabilities(model) AI.put_model_capabilities(model, %{ supports_attachment: existing.supports_attachment, - supports_tool_calls: supports_tool_calls + supports_tool_calls: supports_tool_calls, + disables_reasoning: disables_reasoning }) end @@ -208,6 +231,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do defp model_supports_tool_calls?(model), do: BDS.AI.Catalog.model_capabilities(model).supports_tool_calls + defp model_disables_reasoning?(nil), do: false + defp model_disables_reasoning?(""), do: false + + defp model_disables_reasoning?(model), + do: BDS.AI.Catalog.model_capabilities(model).disables_reasoning + defp put_endpoint_preferences(kind, url, api_key, primary_model) do if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do AI.delete_endpoint(kind) diff --git a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex index 0892a0f..a3baf5e 100644 --- a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex +++ b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex @@ -233,6 +233,10 @@
+
+
+
+
@@ -266,6 +270,10 @@
+
+
+
+
diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index da158de..4395412 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -361,6 +361,31 @@ defmodule BDS.AITest do refute Map.has_key?(payload, "tool_choice") end + test "openai-compatible generation disables thinking for configured models" do + Application.put_env(:bds, :test_pid, self()) + + server = + start_supervised!({Bandit, plug: RecordingCompletionServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + assert :ok = BDS.AI.put_model_capabilities("qwen3.5-122b", %{disables_reasoning: true}) + + assert {:ok, %{content: "Short Title"}} = + BDS.AI.OpenAICompatibleRuntime.generate( + %{url: "http://127.0.0.1:#{port}/v1", api_key: nil}, + %{ + operation: :chat_title, + model: "qwen3.5-122b", + messages: [%{"role" => "user", "content" => "Topic: posts per month"}], + max_output_tokens: 256 + }, + [] + ) + + assert_received {:completion_payload, payload} + assert payload["chat_template_kwargs"] == %{"enable_thinking" => false} + end + test "airplane mode routes title tasks to airplane endpoint and offline title model" do assert {:ok, _endpoint} = BDS.AI.put_endpoint( @@ -506,7 +531,8 @@ defmodule BDS.AITest do assert :ok = BDS.AI.put_model_capabilities("llama3.2", %{ supports_attachment: true, - supports_tool_calls: false + supports_tool_calls: false, + disables_reasoning: true }) assert {:ok, analysis} = @@ -529,6 +555,7 @@ defmodule BDS.AITest do assert endpoint.kind == :airplane assert request.operation == :analyze_image assert request.model == "llama3.2" + assert BDS.AI.Catalog.model_capabilities("llama3.2").disables_reasoning end test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do @@ -702,7 +729,52 @@ defmodule BDS.AITest do assert_received {:runtime_request, _endpoint, title_request} assert title_request.operation == :chat_title assert title_request.model == "title-model" + assert title_request.max_output_tokens == 256 assert Enum.any?(title_request.messages, &(&1["content"] =~ "2-3 words")) + assert Enum.any?(title_request.messages, &(&1["content"] =~ "Do not include reasoning")) + assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items")) + end + + test "chat retries title generation on later turns while the title is still generated" do + {:ok, project} = create_project_fixture("Retry Title Chat") + _fixtures = seed_project_content(project.id) + + assert {:ok, _endpoint} = + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, + secret_backend: FakeSecretBackend + ) + + assert :ok = BDS.AI.set_airplane_mode(false) + assert :ok = BDS.AI.put_model_preference(:title, "title-model") + assert {:ok, conversation} = BDS.AI.start_chat(%{title: "New Chat", model: "gpt-4o-mini"}) + + Repo.insert!(%BDS.AI.ChatMessage{ + conversation_id: conversation.id, + role: :user, + content: "Earlier turn whose title attempt failed", + created_at: Persistence.now_ms() + }) + + assert {:ok, reply} = + BDS.AI.send_chat_message(conversation.id, "How many items are in the blog now?", + runtime: FakeRuntime, + test_pid: self(), + project_id: project.id, + secret_backend: FakeSecretBackend + ) + + assert reply.conversation.title == "Blog Stats" + assert BDS.AI.get_chat_conversation(conversation.id).title == "Blog Stats" + + assert_received {:runtime_request, _endpoint, %{operation: :chat}} + assert_received {:runtime_request, _endpoint, %{operation: :chat}} + assert_received {:runtime_request, _endpoint, %{operation: :chat_title} = title_request} assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items")) end diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index a33f81c..deb981e 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -93,6 +93,39 @@ defmodule BDS.Desktop.ShellLiveTest do end end + defmodule TitleChatServer do + use Plug.Router + import Phoenix.ConnTest, except: [post: 2] + + plug(:match) + plug(:dispatch) + + post "/v1/chat/completions" do + {:ok, request_body, conn} = Plug.Conn.read_body(conn) + request = Jason.decode!(request_body) + send(Application.fetch_env!(:bds, :test_pid), {:title_chat_request, request}) + + content = + if Enum.any?(request["messages"] || [], fn message -> + String.contains?(message["content"] || "", "Generate an ultra-short title") + end) do + "Posts 2026" + else + "Ich habe die Posts pro Monat ermittelt." + end + + body = + Jason.encode!(%{ + "choices" => [%{"message" => %{"content" => content}}], + "usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5} + }) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> send_resp(200, body) + end + end + @endpoint BDS.Desktop.Endpoint setup do @@ -772,6 +805,8 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Offline Endpoint URL" assert html =~ "Online API Key" assert html =~ "Offline API Key" + assert html =~ "Online Chat Reasoning" + assert html =~ "Offline Chat Reasoning" refute html =~ "Mistral API Key" refute html =~ "Anthropic / Online API Key" @@ -782,12 +817,14 @@ defmodule BDS.Desktop.ShellLiveTest do "online_api_key" => "online-secret", "online_chat_model" => "gpt-4.1", "online_chat_tools" => "true", + "online_chat_disable_reasoning" => "true", "online_title_model" => "gpt-4.1-mini", "online_image_analysis_model" => "gpt-4.1-vision", "offline_url" => "http://localhost:11434/v1", "offline_api_key" => "", "offline_chat_model" => "llama3.3", "offline_chat_tools" => "true", + "offline_chat_disable_reasoning" => "true", "offline_title_model" => "llama3.2", "offline_image_analysis_model" => "llava:latest", "offline_mode" => "true", @@ -816,8 +853,11 @@ defmodule BDS.Desktop.ShellLiveTest do assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title) assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis) - assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("gpt-4.1") - assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("llama3.3") + assert %{supports_tool_calls: true, disables_reasoning: true} = + BDS.AI.Catalog.model_capabilities("gpt-4.1") + + assert %{supports_tool_calls: true, disables_reasoning: true} = + BDS.AI.Catalog.model_capabilities("llama3.3") end test "ai settings refresh models from the configured endpoints" do @@ -2195,7 +2235,9 @@ defmodule BDS.Desktop.ShellLiveTest do css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) assert css =~ ".chat-panel-title {" assert css =~ "overflow: visible;" - refute css =~ ".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;" + + refute css =~ + ".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;" render_click(view, "select_chat_model", %{"model" => "llama-next"}) @@ -2203,6 +2245,70 @@ defmodule BDS.Desktop.ShellLiveTest do assert render(view) =~ "llama-next" end + test "chat editor updates the visible new-chat title after the first turn" do + Application.put_env(:bds, :test_pid, self()) + assert :ok = AI.set_airplane_mode(false) + + server = + start_supervised!({Bandit, plug: TitleChatServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + assert {:ok, _endpoint} = + AI.put_endpoint(:online, %{ + url: "http://127.0.0.1:#{port}/v1", + api_key: "online-secret", + model: "gpt-4.1" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "New Chat", model: "gpt-4.1"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + html = render_click(view, "select_view", %{"view" => "chat"}) + + assert html =~ ~s(New Chat) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert html =~ ~s(New Chat) + + _html = + render_change(view, "change_chat_editor_input", %{"message" => "Posts pro Monat 2026"}) + + _html = + view + |> element("[data-testid='chat-send-button']") + |> render_click() + + Process.sleep(350) + html = render(view) + + assert_received {:title_chat_request, chat_request} + + refute Enum.any?(chat_request["messages"] || [], fn message -> + String.contains?(message["content"] || "", "Generate an ultra-short title") + end) + + assert_received {:title_chat_request, title_request} + + assert Enum.any?(title_request["messages"] || [], fn message -> + String.contains?(message["content"] || "", "Generate an ultra-short title") + end) + + assert AI.get_chat_conversation(conversation.id).title == "Posts 2026" + assert html =~ ~s(Posts 2026) + assert html =~ ~r/\s*Posts 2026\s*<\/span>/ + assert html =~ ~s(Posts 2026) + refute html =~ ~s(New Chat) + refute html =~ ~s(New Chat) + end + test "chat editor renders legacy model controls, collapsed tool pills, and dismissible A2UI surfaces" do assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"}) @@ -2433,7 +2539,9 @@ defmodule BDS.Desktop.ShellLiveTest do test "chat editor hook reopens server-expanded A2UI surfaces after patches" do live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__)) - chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__)) + + chat_editor = + File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__)) assert chat_editor =~ "data-expanded={surface_expanded_attr(@surface)}" assert live_js =~ "this.syncExpandedSurfaces = () =>" @@ -2508,7 +2616,8 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Here is the chart." assert html =~ ~s(Assistant) - assert length(:binary.matches(html, ~s(Assistant))) == 1 + assert length(:binary.matches(html, ~s(Assistant))) == + 1 end test "chat editor marks user message text as compact" do